# Introduction > What iced-cube is and how it fits with iced. iced-cube is a set of components for [iced](https://iced.rs), the Rust GUI library. It covers buttons, form controls (inputs, textareas, checkboxes, radios, switches, selects, sliders and labelled fields), layout pieces (cards, stacks, separators, scroll areas and accordions), tabs, tooltips, toasts, feedback such as alerts, badges, progress bars and spinners, and application chrome: a menubar, a toolbar, a status bar and a command palette. They all draw from one set of design tokens, so they look like they belong together in light and dark mode. More components are on the way. The [Status](https://sam-ruff.github.io/iced-cube/docs/status.md) page lists what is planned. For AI agents and other tools, the whole documentation is also available as plain Markdown with the examples as code, indexed at [llms.txt](https://sam-ruff.github.io/iced-cube/llms.txt) with everything in one file at [llms-full.txt](https://sam-ruff.github.io/iced-cube/llms-full.txt). ## How it fits with iced Every component is an ordinary iced widget, or a small composition of them. You build a component with a function, set options on it, and convert it into an `Element` like anything else: ```rust use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; let save = button("Save") .icon(lucide!(Save)) .variant(Variant::Primary) .on_press(Message::Save); ``` There is no separate runtime and no global state. The only macro is `lucide!`, which picks an icon at compile time. Your application still owns its state and handles its own messages. ## Principles - **You own the state.** Components that need state, such as tabs or toasts, give you a plain struct with a pure `update` function. You store it and pass it back in. - **Colours come from the theme.** Every colour is read from the active theme, so custom palettes and dark mode need no extra work. - **Channels for background work.** Toasts raised by other threads arrive through a subscription that owns a channel. See [Subscriptions](https://sam-ruff.github.io/iced-cube/docs/subscriptions.md). - **Tested.** Each component ships with unit tests, headless interaction tests and image snapshots in both themes. ## Live previews The previews are the real Rust examples compiled to WebAssembly. The code under each preview is the exact file that produced it. --- # Installation > Add the crate and render your first component. ## Add the crate ```sh cargo add iced@0.14 iced-cube ``` iced-cube is built for iced 0.14, so pin iced to that version. A newer iced would sit alongside the 0.14 copy that iced-cube uses, and their types would not match. ## Your first app Replace `src/main.rs` with this and run `cargo run`: ```rust use iced::Element; use iced::widget::{column, row, text}; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide, theme}; #[derive(Debug, Clone)] enum Message { Increment, Reset, } #[derive(Default)] struct App { count: u32, } impl App { fn update(&mut self, message: Message) { match message { Message::Increment => self.count += 1, Message::Reset => self.count = 0, } } fn view(&self) -> Element<'_, Message> { // A button without a message renders disabled. let reset = (self.count > 0).then_some(Message::Reset); column![ text(format!("Pressed {} times", self.count)), row![ button("Press me") .icon(lucide!(Plus)) .on_press(Message::Increment), button("Reset") .variant(Variant::Secondary) .on_press_maybe(reset), ] .spacing(8), ] .spacing(12) .padding(24) .into() } } fn main() -> iced::Result { iced::application(App::default, App::update, App::view) .theme(|_: &App| theme::light()) .run() } ``` Components read their colours from whichever iced theme is active. `theme::light()` and `theme::dark()` are tuned for them, but any iced theme works. See [Theming](https://sam-ruff.github.io/iced-cube/docs/theming.md). ## Running an example from these docs Every example on a component page is a single file with the same shape: a `Message` enum, an `Example` struct that implements `Default`, and `update` and `view` methods. Some also have a `subscription` method. To run one on your machine, start a project as above, open the example's Code tab, and save the code as `src/example.rs`. Then point `src/main.rs` at it: ```rust mod example; use example::Example; fn main() -> iced::Result { iced::application(Example::default, Example::update, Example::view) .theme(|_: &Example| iced_cube::theme::light()) .run() } ``` If the example has a `subscription` method, such as the spinner or toast examples, register it too, or it will sit still: ```rust fn main() -> iced::Result { iced::application(Example::default, Example::update, Example::view) .theme(|_: &Example| iced_cube::theme::light()) .subscription(Example::subscription) .run() } ``` The toast background job example also needs `cargo add futures-timer`. ## Choosing components By default you get every component. To compile only the ones you use, turn off the defaults and name the components you want. Each component's feature has the same name as its page in these docs, such as `button`, `dropdown-menu` or `scroll-area`: ```sh cargo add iced-cube --no-default-features --features button,input,dialog ``` In `Cargo.toml` that reads: ```toml [dependencies] iced = "0.14" iced-cube = { version = "0.0", default-features = false, features = ["button", "input", "dialog"] } ``` A component turns on anything it is built from, so `dialog` brings in `button` and `icon-button` for you. You can also ask for a whole group: `primitives`, `forms`, `layout`, `navigation`, `overlay`, `data`, `feedback` or `application`. `full` is every component, and it is what the defaults include. The theme, icons and keyboard shortcut modules are always there. Turning the defaults off also drops `x11`, `wayland` and `thread-pool`, described below. That makes no difference in an app that depends on iced with its own defaults, which is the usual case. ## Other features - `x11`, `wayland` and `thread-pool` are on by default and turn on the iced features of the same name. They let the crate build on its own and on docs.rs. In your app, iced's own default features already turn them on, so switching them off in iced-cube changes nothing unless you also turn off iced's defaults. - `tokio` turns on iced's `tokio` feature, which makes tokio iced's executor, and makes the toast timer use `iced::time::every`. Without it, the toast timer runs on a small thread that sleeps between ticks, so toasts close on time whichever executor you use. If your app already runs on tokio, add it with `cargo add iced-cube --features tokio`. In the browser the toast timer always uses `iced::time::every`, and no feature is needed. ## Next steps - Browse the components, starting with [Button](https://sam-ruff.github.io/iced-cube/docs/components/button.md). - Match your brand with [Theming](https://sam-ruff.github.io/iced-cube/docs/theming.md). - Pick icons in [Icons](https://sam-ruff.github.io/iced-cube/docs/icons.md). - Send toasts from background work with [Subscriptions](https://sam-ruff.github.io/iced-cube/docs/subscriptions.md). - Write headless tests for your UI with [Testing](https://sam-ruff.github.io/iced-cube/docs/testing.md). --- # Theming > Light and dark themes, tokens and custom palettes. ## Built-in themes `iced_cube::theme::light()` and `iced_cube::theme::dark()` return ordinary `iced::Theme` values with a neutral palette. Switch between them from your application's `theme` function. To use your own colours, see [Custom palettes](#custom-palettes) below. These are the same controls under each built-in theme and a custom palette. Each panel sets its own theme with iced's `themer` widget, so the panels keep their colours whatever the site theme is: Light, dark and custom: The same controls under the light theme, the dark theme and a Config palette, side by side. ```rust use iced::widget::{column, container, row, text, themer}; use iced::{Background, Element, Length, Theme, color}; use iced_cube::primitives::button::Variant; use iced_cube::theme::{self, Config, Tokens, radius}; use iced_cube::{badge, button, checkbox, switch}; #[derive(Debug, Clone)] pub enum Message { Toggled(bool), Checked(bool), Pressed, } #[derive(Debug)] pub struct Example { on: bool, checked: bool, } impl Default for Example { fn default() -> Self { Self { on: true, checked: true, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Toggled(on) => self.on = on, Message::Checked(checked) => self.checked = checked, Message::Pressed => {} } } pub fn view(&self) -> Element<'_, Message> { let brand = Config::light() .name("Brand") .primary(color!(0x4f46e5)) .accent(color!(0xeef2ff)) .build(); row![ self.panel("Light", theme::light()), self.panel("Dark", theme::dark()), self.panel("Custom", brand), ] .spacing(12) .wrap() .into() } /// The same controls rendered under one theme, whatever the app's theme is. fn panel(&self, name: &'static str, theme: Theme) -> Element<'_, Message> { let content = column![ text(name).size(14).font(theme::semibold()), row![ button("Save").on_press(Message::Pressed), button("Cancel") .variant(Variant::Outline) .on_press(Message::Pressed), ] .spacing(6), switch(self.on).label("Sync").on_toggle(Message::Toggled), checkbox(self.checked) .label("Notify me") .on_toggle(Message::Checked), badge("New"), ] .spacing(12); themer( Some(theme), container(content) .padding(16) .width(Length::Fixed(200.0)) .style(|theme: &Theme| { let tokens = Tokens::of(theme); container::Style { background: Some(Background::Color(tokens.background)), border: iced::Border { color: tokens.border, width: 1.0, radius: radius::LG.into(), }, text_color: Some(tokens.foreground), ..container::Style::default() } }), ) .into() } } ``` ## Tokens Components never hard-code colours. They resolve semantic tokens from the active theme: ```rust use iced_cube::theme::Tokens; let tokens = Tokens::of(&theme); let border = tokens.border; let text = tokens.muted_foreground; ``` Tokens include `background`, `foreground`, `popover`, `muted`, `muted_foreground`, `accent`, `border`, `ring`, `primary`, `secondary`, `destructive`, `success` and `warning`, each with a matching foreground where it makes sense. `popover` is the surface of menus, popovers, lists and dialogs: the background in light themes, lifted a little in dark ones so a menu over a card still looks raised. Here they are as the current theme resolves them; switch the site between light and dark to see both sets: Tokens: Every semantic colour token, as resolved from the current theme. ```rust use iced::widget::{Space, column, container, responsive, row, text}; use iced::{Background, Color, Element, Length, Theme}; use iced_cube::theme::{Tokens, radius}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; /// Reads one colour from the resolved tokens. type Pick = fn(&Tokens) -> Color; /// Every token and how to read it from [`Tokens`]. const SWATCHES: [(&str, Pick); 12] = [ ("background", |t| t.background), ("foreground", |t| t.foreground), ("muted", |t| t.muted), ("muted_foreground", |t| t.muted_foreground), ("border", |t| t.border), ("ring", |t| t.ring), ("primary", |t| t.primary), ("secondary", |t| t.secondary), ("accent", |t| t.accent), ("destructive", |t| t.destructive), ("success", |t| t.success), ("warning", |t| t.warning), ]; impl Example { pub fn update(&mut self, message: Message) { match message {} } pub fn view(&self) -> Element<'_, Message> { responsive(|size| { let columns = if size.width >= 596.0 { 4 } else if size.width >= 252.0 { 2 } else { 1 }; let width = ((size.width - 12.0 * (columns - 1) as f32) / columns as f32).clamp(0.0, 140.0); let rows = SWATCHES.chunks(columns).map(|chunk| { row(chunk .iter() .map(|&(name, colour)| swatch(name, colour, width))) .spacing(12) .into() }); column(rows).spacing(14).into() }) .width(Length::Shrink) .height(Length::Shrink) .into() } } fn swatch(name: &'static str, colour: Pick, width: f32) -> Element<'static, Message> { let chip = container(Space::new()) .width(Length::Fixed(width)) .height(Length::Fixed(36.0)) .style(move |theme: &Theme| { let tokens = Tokens::of(theme); container::Style { background: Some(Background::Color(colour(&tokens))), border: iced::Border { color: tokens.border, width: 1.0, radius: radius::MD.into(), }, ..container::Style::default() } }); column![chip, text(name).size(12)].spacing(4).into() } ``` Because tokens come from iced's extended palette, any iced theme works, including the built-in ones such as `Theme::Dracula` or a `Theme::custom` palette of your own. `secondary` and `accent` are read from the extended palette's secondary colours, which iced derives from the background and text for its own themes. ## Spacing, radius and type Shared constants keep layouts consistent: - `theme::space`: `XS` (4), `SM` (8), `MD` (12), `LG` (16), `XL` (24) - `theme::radius`: `SM` (4), `MD` (6), `LG` (8), `FULL` - `theme::text_size`: `XS` (12), `SM` (14), `MD` (16), `LG` (18) ## Fonts Emphasised text, such as card and toast titles, is drawn at semibold weight. So that it uses your font rather than whatever sans-serif the system picks, register the same font you give iced as its default, before the app starts: ```rust use iced::Font; const FONT: Font = Font::with_name("Inter"); fn main() -> iced::Result { iced_cube::theme::set_font(FONT); iced::application(App::default, App::update, App::view) .font(include_bytes!("../fonts/Inter-Regular.ttf").as_slice()) .font(include_bytes!("../fonts/Inter-SemiBold.ttf").as_slice()) .default_font(FONT) .run() } ``` Load a semibold face of the font too, or the text renderer will fall back to a synthetic or system bold. ## Changing one component Components have no per-instance style option. Their look comes from the theme alone, so a palette change reaches every one of them at once. When one spot in your app needs something different, build it from a plain iced widget and start from the component's own style. Each component module has a pure `style` function that takes `Tokens` (and the variant or status where it has one) and returns an ordinary iced style, which you can adjust before returning it: ```rust use iced::widget::{button as iced_button, container, text}; use iced::{Element, Theme}; use iced_cube::card; use iced_cube::primitives::button::{self, Variant}; use iced_cube::theme::{Tokens, radius}; fn pill_button(label: &str) -> iced::widget::Button<'_, Message> { iced_button(text(label).size(14)) .padding([8, 20]) .style(|theme: &Theme, status| { let mut style = button::style(&Tokens::of(theme), Variant::Primary, status); style.border.radius = radius::FULL.into(); style }) } fn highlighted_panel<'a>(content: impl Into>) -> Element<'a, Message> { container(content) .padding(16) .style(|theme: &Theme| { let tokens = Tokens::of(theme); let mut style = card::style(&tokens); style.border.color = tokens.primary; style }) .into() } ``` Because the styles still read from `Tokens`, these widgets follow light, dark and custom themes like everything else. You lose the component's builder conveniences, such as sizes and icons, so keep this for the odd exception. ## Custom palettes `theme::Config` builds a theme from your own colours. Start from `Config::light()` or `Config::dark()`, change the colours you care about and call `build()`. Anything you leave alone keeps the default, so `Config::light().build()` is exactly `theme::light()`. ```rust use iced::widget::column; use iced::{Element, Theme, color}; use iced_cube::theme::Config; use iced_cube::{button, switch}; #[derive(Debug, Clone)] enum Message { Dark(bool), } #[derive(Default)] struct App { dark: bool, } fn brand(dark: bool) -> Theme { let config = if dark { Config::dark() .primary(color!(0x818cf8)) .accent(color!(0x1e1b4b)) } else { Config::light() .primary(color!(0x4f46e5)) .accent(color!(0xeef2ff)) }; config .name("Brand") .destructive(color!(0xe11d48)) .build() } impl App { fn update(&mut self, message: Message) { let Message::Dark(dark) = message; self.dark = dark; } fn view(&self) -> Element<'_, Message> { column![ button("Save"), switch(self.dark).label("Dark mode").on_toggle(Message::Dark), ] .spacing(12) .into() } fn theme(&self) -> Theme { brand(self.dark) } } fn main() -> iced::Result { iced::application(App::default, App::update, App::view) .theme(App::theme) .run() } ``` The setters are `primary`, `secondary`, `accent`, `destructive`, `success`, `warning`, `background`, `foreground` and `border`. Text on coloured surfaces, such as a primary button's label, is picked for you with `theme::on`, which chooses near-black or near-white for contrast. `secondary`, `accent` and `border` default to shades of the background, so they follow a new background unless you set them. Whether components use their light or dark styling follows the background colour, so a dark background built from `Config::light()` still gets dark shadows and hover states. Here is a brand palette on buttons, badges, a switch and a progress bar. The switch rebuilds the palette from `Config::dark()`: Custom palette: A brand palette built with theme::Config. The switch rebuilds it from the dark defaults. ```rust use iced::widget::{column, row}; use iced::{Alignment, Element, Theme, color}; use iced_cube::feedback::badge::Variant as BadgeVariant; use iced_cube::primitives::button::Variant; use iced_cube::theme::Config; use iced_cube::{badge, button, progress, switch}; #[derive(Debug, Clone)] pub enum Message { Dark(bool), Save, } #[derive(Debug, Default)] pub struct Example { dark: bool, saves: u8, } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Dark(dark) => self.dark = dark, Message::Save => self.saves = self.saves.saturating_add(1).min(10), } } pub fn view(&self) -> Element<'_, Message> { let buttons = row![ button("Save").on_press(Message::Save), button("Share") .variant(Variant::Secondary) .on_press(Message::Save), button("Preview") .variant(Variant::Outline) .on_press(Message::Save), badge("Beta").variant(BadgeVariant::Secondary), badge("Live"), ] .spacing(8) .align_y(Alignment::Center) .wrap(); column![ buttons, progress(f32::from(self.saves) / 10.0) .label("Saves") .show_percentage(true), switch(self.dark) .label("Dark mode") .on_toggle(Message::Dark), ] .spacing(20) .width(420) .into() } pub fn theme(&self) -> Option { let config = if self.dark { Config::dark() .background(color!(0x1e1b2e)) .foreground(color!(0xf5f3ff)) .primary(color!(0xa78bfa)) .secondary(color!(0x2e2a45)) .accent(color!(0x3b3558)) } else { Config::light() .background(color!(0xfdfcff)) .foreground(color!(0x1e1b2e)) .primary(color!(0x6d28d9)) .secondary(color!(0xede9fe)) .accent(color!(0xf5f3ff)) }; Some(config.name("Violet").build()) } } ``` `build()` fills in iced's extended palette, so plain iced widgets pick up the same colours. If you need the palettes themselves, `config.palette()` and `config.extended()` return them without building a theme. --- # Icons > Lucide icons resolved at compile time. ## Finding an icon Browse the set at [lucide.dev/icons](https://lucide.dev/icons). Names there are kebab-case; in Rust they are PascalCase, so `chevron-right` becomes `ChevronRight` and `arrow-down-0-1` becomes `ArrowDown01`. The icons come from the version of Lucide bundled with the crate, so an icon added to Lucide very recently may not be there yet. The compiler will tell you if a name does not exist. ## Using an icon The `lucide!` macro turns a name into a `Glyph`, the embedded SVG for one icon: ```rust use iced_cube::icon::icon; use iced_cube::{button, lucide}; let save = lucide!(Save); let alone = icon(save, 16.0); let on_a_button = button("Save").icon(save).on_press(Message::Save); ``` Components that show icons, such as buttons, badges, alerts and tabs, take a `Glyph` directly. ## `Icon` and `icon()` Two items have nearly the same name: - `Icon` (re-exported as `iced_cube::Icon`) is the enum of every Lucide icon. `lucide!(Save)` is shorthand for `Glyph::new(Icon::Save)` evaluated in a constant. - `icon()` (in `iced_cube::icon`) is a function that renders a `Glyph` as an iced `svg` widget at a given size. You rarely need `Icon` yourself. It is useful when you want to name a glyph once as a constant: ```rust use iced_cube::icon::Glyph; use iced_cube::Icon; const TRASH: Glyph = Glyph::new(Icon::Trash); ``` ## Only what you use Each `lucide!` call expands to a constant, so only the SVGs you reference end up in your binary. The icons are embedded in the crate, so nothing is downloaded at build time and offline builds work. ## Colour `icon()` draws in the theme's foreground colour. `icon::tinted(glyph, size, Some(colour))` sets a fixed colour instead. Components tint their own icons to match their text. --- # Subscriptions > Feeding components from background work with channels. Some UI updates start outside the UI: an export finishing on a worker thread, a sync task failing, a download completing. In iced-cube these reach the app through a channel owned by a subscription, never through shared `Arc>` state. [Toast](https://sam-ruff.github.io/iced-cube/docs/components/toast.md) is currently the only component fed this way. Later components that stream data in will use the same pattern. ## How the toast channel works - `toast::subscription()` creates a bounded channel and keeps the receiving end. - Its first event is `toast::Event::Ready(sender)`. Passing it to `state.update` returns `toast::Output::Ready(sender)`, and your app stores the sender. - You give a clone of the sender to each producer: a thread, an async task, a callback. - Whatever is waiting in the channel is delivered as one `Event::Received(Vec)` of up to `toast::BATCH_SIZE` (16) toasts, so a burst causes a handful of redraws rather than one per toast. The channel holds `toast::CHANNEL_CAPACITY` (32) toasts, plus one slot per sender clone. Nothing else is shared between threads, and the UI thread never waits on a worker. ## A complete example This app has two buttons. Export runs on a thread, Sync runs as an async task, and both report back with a toast. ```rust use std::thread; use std::time::Duration; use iced::futures::SinkExt; use iced::widget::{column, text}; use iced::{Element, Subscription, Task}; use iced_cube::overlay::toast::{self, Variant, toast, toasts}; use iced_cube::button; #[derive(Debug, Clone)] enum Message { Toast(toast::Event), Export, Sync, } #[derive(Default)] struct App { toasts: toast::State, sender: Option, } impl App { fn update(&mut self, message: Message) -> Task { match message { Message::Toast(event) => { if let Some(toast::Output::Ready(sender)) = self.toasts.update(event) { self.sender = Some(sender); } Task::none() } Message::Export => { let Some(mut sender) = self.sender.clone() else { return Task::none(); }; thread::spawn(move || { thread::sleep(Duration::from_secs(2)); // try_send drops the toast if the channel is full. let _ = sender.try_send(toast("Export finished").variant(Variant::Success)); }); Task::none() } Message::Sync => { let Some(mut sender) = self.sender.clone() else { return Task::none(); }; Task::future(async move { // Await the real work here, then report back. let _ = sender.send(toast("Sync finished")).await; }) .discard() } } } fn view(&self) -> Element<'_, Message> { let ready = self.sender.is_some(); let content = column![ text("Background work reports back through toasts."), button("Export").on_press_maybe(ready.then_some(Message::Export)), button("Sync").on_press_maybe(ready.then_some(Message::Sync)), ] .spacing(12) .padding(24); toasts(&self.toasts, content) .on_event(Message::Toast) .into() } fn subscription(&self) -> Subscription { Subscription::batch([toast::subscription(), toast::timer(&self.toasts)]) .map(Message::Toast) } } fn main() -> iced::Result { iced::application(App::default, App::update, App::view) .subscription(App::subscription) .run() } ``` The buttons stay disabled until `Ready` has arrived, because until then there is no sender to hand out. ## Sending from a thread `try_send` never blocks. It fails when the channel is full or the app has closed, and the toast is dropped. For notifications that is usually what you want: a worker should not stall because nobody is reading. When every message must arrive, block the worker until there is room instead. Add the `futures` crate (`cargo add futures`) and use `block_on` with `SinkExt::send`: ```rust use futures::SinkExt; use futures::executor::block_on; thread::spawn(move || { for n in 1..=100 { // Waits while the channel is full instead of dropping. if block_on(sender.send(toast(format!("Item {n} done")))).is_err() { break; } } }); ``` `send` only fails once the receiver is gone, which means the app has shut down, so that is the moment to stop. ## Sending from async code In async code, `SinkExt::send` waits for room without blocking a thread. The Sync branch above runs it as an iced `Task`. The same works in a `tokio::spawn` task or any other executor, because the sender does not depend on a runtime. ## Stable identity iced compares subscriptions by identity to decide whether a stream is new. `toast::subscription()` is built with `Subscription::run` from a plain function, so it keeps the same identity every time `subscription` is called, and the channel and sender survive for the life of the app. If you write a similar subscription yourself, use `Subscription::run` with a function, or `Subscription::run_with` with a stable, hashable id. ## Timers only when needed `toast::timer(&state)` returns `Subscription::none()` unless a visible toast is counting down. While one is, it emits `Event::Tick` every `toast::TICK` (250 ms), and the tick closes any toast whose time is up. Persistent toasts and toasts waiting behind the visible ones do not keep it running, so an app with no toasts on screen is idle. The [Spinner](https://sam-ruff.github.io/iced-cube/docs/components/spinner.md) follows the same idea without a timer of its own. It has no clock: your app keeps its phase, subscribes to `iced::window::frames()` only while something is loading, and moves the phase on with `spinner::advance`. --- # Keyboard shortcuts > Default shortcuts and how to change them with a keymap. Interactive components such as [Tabs](https://sam-ruff.github.io/iced-cube/docs/components/tabs.md), [Slider](https://sam-ruff.github.io/iced-cube/docs/components/slider.md) and [Toast](https://sam-ruff.github.io/iced-cube/docs/components/toast.md) come with default keyboard shortcuts. Each component page lists its defaults under Keyboard. Each component has an `Action` enum and a `default_keymap()`, a `Keymap` from chords to actions. `keymap.resolve_event(&key)` returns the action for a key press, or `None`, and a pure function on the action turns it into the component's own event or value, such as `action.event(&state)` for tabs or `action.apply(value, range, step)` for a slider. ## Where a key press goes iced has no general focus model, so iced-cube follows one rule: a key press goes to the innermost layer first, and each layer either uses it or passes it on. - **Open overlays.** An open dropdown menu or context menu, and the list of a combobox or select, resolve their own keymap before anything underneath sees the key. Overlays nest, so Escape in a menu inside a popover closes the menu and leaves the popover open. A popover checks its content first, then closes on the chords bound to its Close action. - **The focused field.** A focused text field keeps the keys it uses, such as letters, arrows, Home and End. A combobox or command list resolves its keymap while its field has focus, so Escape clears the query before anything around it closes. - **An open dialog.** A dialog captures every key press its content leaves, so nothing behind the scrim reacts. Let chosen chords through with `.pass_through([...])`, for example the shortcut that toggles a command palette. - **Your app.** Whatever is left arrives from `keys::subscription()`, and your app decides which keymap resolves it. This is how tabs, sliders, toasts and other app-wide shortcuts work, and how a closed menu opens from a chord bound to its Open action. A closed menu claims no keys, so the arrow keys, Enter and Space stay free for the rest of your app. Components that handle keys themselves take a changed keymap through `.keymap(...)`, and that keymap also decides which keys close them: unbind Escape there and Escape no longer closes the component. The same words mean the same thing everywhere. Highlight moves the highlight to a row, Activate chooses it, Close closes a menu or list, and an overlay the app opens with a flag, such as a popover or dialog, reports that it wants to close through `on_dismiss`. ## Several components on one screen When components share a screen, the order above decides who gets a key: the innermost open overlay or focused component first, then whatever keymaps your app resolves from `keys::subscription()`. A focused list, tree, data table or log viewer keeps the keys it uses, so app-wide keymaps only see them while nothing has focus. A few defaults overlap. Each has a simple fix: - **`Ctrl+Tab` and `Ctrl+Shift+Tab`**: [Tabs](https://sam-ruff.github.io/iced-cube/docs/components/tabs.md) and [Document tabs](https://sam-ruff.github.io/iced-cube/docs/components/document-tabs.md) both bind them, and both are resolved by your app. With both on screen, unbind them from one keymap, usually the segmented tabs, which still have their arrow keys. - **`PageUp` and `PageDown`**: [Pagination](https://sam-ruff.github.io/iced-cube/docs/components/pagination.md) binds them app-wide, while a focused [virtual list](https://sam-ruff.github.io/iced-cube/docs/components/virtual-list.md), [data table](https://sam-ruff.github.io/iced-cube/docs/components/data-table.md) or [log viewer](https://sam-ruff.github.io/iced-cube/docs/components/log-viewer.md) uses them to move a page at a time. The focused component wins, so pagination only turns the page while nothing else has focus. If a table and its pagination sit together and that feels surprising, unbind them from pagination and leave paging to its buttons. - **`Shift+F10` and `Menu`**: these open a [context menu](https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md) from the keyboard. Trees and data tables open their row menu only while they have focus. A context menu your app opens from a chord should be resolved for one target at a time, such as the focused panel. Document tabs leave their menu chord unbound for this reason; bind it only when the tab strip is the only context menu on screen. ## Subscribing to key presses `keys::subscription()` delivers every key press that no widget captured, so typing in a text input never switches tabs, and nothing reaches it while a dialog is open. A modifier key on its own, such as Alt, arrives only when it is released with nothing else pressed in between, so `Alt+ArrowUp` is one chord and never also a bare Alt. Apps that listen to window events themselves get the same behaviour from `keys::Tracker`. ## A complete example This app keeps the tab shortcuts but drops the plain arrow keys, which it wants for something else, and adds `Ctrl+PageDown` and `Ctrl+PageUp`: ```rust use iced::keyboard::key::Named; use iced::widget::{column, text}; use iced::{Element, Subscription}; use iced_cube::keys::{self, Chord, Keymap}; use iced_cube::navigation::tabs::{self, Action, State, tab, tabs}; #[derive(Debug, Clone)] enum Message { Key(keys::Event), Tabs(tabs::Event), } #[derive(Debug, Clone, Copy, PartialEq)] enum Page { Account, Billing, Team, } struct App { tabs: State, keymap: Keymap, } impl Default for App { fn default() -> Self { let keymap = tabs::default_keymap() .unbind(&Chord::named(Named::ArrowLeft)) .unbind(&Chord::named(Named::ArrowRight)) .bind(Chord::named(Named::PageDown).ctrl(), Action::Next) .bind(Chord::named(Named::PageUp).ctrl(), Action::Previous); Self { tabs: State::new([ tab(Page::Account, "Account"), tab(Page::Billing, "Billing"), tab(Page::Team, "Team"), ]), keymap, } } } impl App { fn update(&mut self, message: Message) { let event = match message { Message::Key(key) => self .keymap .resolve_event(&key) .and_then(|action| action.event(&self.tabs)), Message::Tabs(event) => Some(event), }; if let Some(event) = event { let _ = self.tabs.update(event); } } fn view(&self) -> Element<'_, Message> { let page = match self.tabs.selected() { Some(Page::Account) => "Account settings", Some(Page::Billing) => "Invoices and payment methods", Some(Page::Team) => "People with access", None => "", }; column![tabs(&self.tabs).on_event(Message::Tabs), text(page)] .spacing(16) .padding(24) .into() } fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } fn main() -> iced::Result { iced::application(App::default, App::update, App::view) .subscription(App::subscription) .run() } ``` The keymap lives in your app's state, so it can change at runtime, for example from a settings screen. Keymaps are plain values: `bind`, `unbind` and `clear` each return the changed keymap. ## Changing a keymap - `bind(chord, action)` adds a chord. If the chord already did something, it now does this instead. - `unbind(&chord)` removes one chord. `unbind_action(&action)` removes every chord for an action. - `clear()` removes everything, so you can build the keymap from scratch. - `grouped()` lists the chords for each action, which is handy for a help screen. When two components you route yourself want the same key, such as the arrow keys for tabs and a slider, decide in `update` which keymap to ask. For example, resolve through the slider's keymap while the slider is the current control, and fall back to the tabs keymap otherwise. Menus, lists and dialogs never need this, because they take their keys before your app sees them. ## Chord syntax A `Chord` is a key plus the modifiers held with it. Build one in code with `Chord::named(Named::Tab).ctrl()` or `Chord::character('k')`, or parse it from a string, which suits settings files: ```rust use iced_cube::keys::Chord; let chord: Chord = "Ctrl+Shift+Tab".parse().expect("valid chord"); assert_eq!(chord.to_string(), "Ctrl+Shift+Tab"); ``` - Join modifiers and the key with `+`. Case does not matter, so `ctrl+k` and `Ctrl+K` are the same chord. - Modifiers are `Ctrl`, `Shift`, `Alt` (or `Option`) and `Cmd` (or `Super` or `Meta`). `Mod` means Cmd on macOS and Ctrl on Linux and Windows, for shortcuts that should feel native on each. - A key is a single character, such as `K`, `/` or `+`, or a name: `Enter`, `Tab`, `Space`, `Escape`, `Backspace`, `Delete`, `Insert`, `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Home`, `End`, `PageUp`, `PageDown` and `F1` to `F12`. Short forms such as `Esc`, `Up` and `PgDn` also work. - A chord matches only its exact modifiers: `Ctrl+K` does not fire for `Ctrl+Shift+K`. Displaying a chord gives the canonical form, with modifiers in the order Ctrl, Alt, Shift, Cmd, which is what the component pages show. ## Built-in widget keys Some keys are handled inside iced's own widgets rather than through a keymap, such as text editing in [Input](https://sam-ruff.github.io/iced-cube/docs/components/input.md) and [Textarea](https://sam-ruff.github.io/iced-cube/docs/components/textarea.md). Those are listed on their component pages and cannot be rebound. While one of these widgets has focus it captures the keys it handles, so they never reach `keys::subscription()`. Keys it ignores, such as `Ctrl+Tab`, still do. --- # Testing > Headless tests for your own UI with iced_test. iced-cube components are ordinary iced widgets, so you can test a UI built with them the same way iced-cube tests itself: with the headless simulator from `iced_test`. It lays out your view, lets you click and type, and hands back the messages your app would have received. No window or GPU is needed. ## Setup ```sh cargo add --dev iced_test@0.14 ``` The simulator renders with whichever backend iced picks. To render on the CPU, so tests behave the same on every machine and in CI, add this to `.cargo/config.toml`: ```toml [env] ICED_TEST_BACKEND = "tiny-skia" ``` ## What the simulator can do - `simulator(element)` builds a simulator from any `Element`, usually `app.view()`. - `ui.click(selector)` clicks a widget found by its text or by its widget `Id`. - `ui.typewrite(text)` types into whichever input has focus, and `ui.tap_key(key)` presses one key. - `ui.find(selector)` checks that something is on screen. - `ui.into_messages()` ends the run and returns the messages it produced, in order. The simulator never calls your `update`. Feed the messages back into your app yourself, then build a new simulator from the new view to check the result. ## An example A form with a name input and a Save button that raises a toast. The input has an `id`, so the test can click it to give it focus, and `toast::close_button_id` gives each toast's close button an id to click. The app and its tests share one file here, such as `tests/app.rs`, so the example stands on its own. In a real project, put the tests in a `#[cfg(test)] mod tests` next to your `App`, or move the app into a library crate that the files under `tests/` can import. ```rust use iced::Element; use iced::widget::{Id, column}; use iced_cube::overlay::toast::{self, toast, toasts}; use iced_cube::{button, input}; use iced_test::{Error, simulator}; #[derive(Debug, Clone)] enum Message { Name(String), Save, Toast(toast::Event), } #[derive(Default)] struct App { name: String, toasts: toast::State, } impl App { fn update(&mut self, message: Message) { match message { Message::Name(name) => self.name = name, Message::Save => { self.toasts.push(toast(format!("Saved {}", self.name))); } Message::Toast(event) => { let _ = self.toasts.update(event); } } } fn view(&self) -> Element<'_, Message> { let save = (!self.name.is_empty()).then_some(Message::Save); let form = column![ input("Your name", &self.name) .id("name") .on_input(Message::Name), button("Save").on_press_maybe(save), ]; toasts(&self.toasts, form) .on_event(Message::Toast) .into() } } /// Feeds every message a simulator recorded back into the app. fn apply(app: &mut App, messages: impl IntoIterator) { for message in messages { app.update(message); } } #[test] fn typing_a_name_and_saving_shows_a_toast() -> Result<(), Error> { let mut app = App::default(); let mut ui = simulator(app.view()); ui.click(Id::new("name"))?; ui.typewrite("Ada"); let messages: Vec<_> = ui.into_messages().collect(); apply(&mut app, messages); assert_eq!(app.name, "Ada"); let mut ui = simulator(app.view()); ui.click("Save")?; let messages: Vec<_> = ui.into_messages().collect(); apply(&mut app, messages); let mut ui = simulator(app.view()); assert!(ui.find("Saved Ada").is_ok()); Ok(()) } #[test] fn save_is_disabled_until_a_name_is_typed() -> Result<(), Error> { let app = App::default(); let mut ui = simulator(app.view()); ui.click("Save")?; assert_eq!(ui.into_messages().count(), 0); Ok(()) } #[test] fn closing_a_toast_removes_it() -> Result<(), Error> { let mut app = App::default(); let id = app.toasts.push(toast("Saved Ada")); let mut ui = simulator(app.view()); ui.click(toast::close_button_id(id))?; let messages: Vec<_> = ui.into_messages().collect(); apply(&mut app, messages); assert!(app.toasts.is_empty()); Ok(()) } ``` ## Testing state without a view Stateful components keep their logic in plain structs, so much of it can be tested without the simulator at all. `tabs::State`, `accordion::State` and `toast::State` each have an `update` you can call directly. Toasts take the current time through `Event::Tick` (or `state.tick(now)`), so expiry can be tested with any `Instant` you like instead of waiting. --- # Status > Version, platforms, minimum Rust and what is planned. ## Version and stability iced-cube is still on 0.0.x, and every release may include breaking changes: builder methods and variant names can move as more components arrive. Cargo treats each 0.0.x release as incompatible with the last, so a requirement such as `iced-cube = "0.0.2"` stays on that release until you change it. Read the changelog before you upgrade. Each release targets one iced version, currently 0.14. ## Supported platforms - **Linux** (X11 and Wayland) and **Windows** are built and tested on every change. - **The browser** (WebAssembly with WebGL) runs every example on this site. - **macOS** should work wherever iced does, but it is not tested yet. Screen reader support is limited to what iced itself offers. ## Minimum Rust Rust 1.88 or newer, which is what iced 0.14 needs. iced-cube uses the 2024 edition. Only the current stable toolchain is tested. ## Planned components These are next on the roadmap, roughly in order: - File tree - Virtual tree - Inspector - App shell ## Changelog Every release is listed in the [changelog](https://github.com/sam-ruff/iced-cube/blob/main/CHANGELOG.md), generated from the commit history. --- # Button > Triggers an action, with consistent variants, sizes and states. ```rust use iced_cube::{button, lucide}; use iced_cube::primitives::button::{Size, Variant}; ``` ## Usage Use one primary button per view for the main action, and secondary, outline or ghost buttons for everything else. Destructive buttons are for actions that delete or cannot be undone. For a button that shows only an icon, such as a toolbar toggle or a close button, use [Icon Button](https://sam-ruff.github.io/iced-cube/docs/components/icon-button.md). Icons come from Lucide through the `lucide!` macro, which resolves the icon at compile time so only the icons you use are included in your binary. ## Examples ### Variants Every visual variant, from primary to link. ```rust use iced::Element; use iced::widget::row; use iced_cube::button; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone)] pub enum Message { Pressed(Variant), } #[derive(Debug, Default)] pub struct Example { last: Option, } impl Example { pub fn update(&mut self, message: Message) { let Message::Pressed(variant) = message; self.last = Some(variant); } pub fn view(&self) -> Element<'_, Message> { row(Variant::ALL.map(|variant| { button(format!("{variant:?}")) .variant(variant) .on_press(Message::Pressed(variant)) .into() })) .spacing(8) .wrap() .into() } } ``` ### Sizes Small, medium and large, plus the square icon size. ```rust use iced::widget::row; use iced::{Alignment, Element}; use iced_cube::primitives::button::Size; use iced_cube::{button, icon_button, lucide}; #[derive(Debug, Clone)] pub enum Message { Pressed, } #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { row![ button("Small").size(Size::Sm).on_press(Message::Pressed), button("Medium").size(Size::Md).on_press(Message::Pressed), button("Large").size(Size::Lg).on_press(Message::Pressed), icon_button(lucide!(ArrowUpRight)).on_press(Message::Pressed), ] .spacing(8) .align_y(Alignment::Center) .into() } } ``` ### With icon Lucide icons before or after the label. ```rust use iced::Element; use iced::widget::row; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; #[derive(Debug, Clone)] pub enum Message { Pressed, } #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { row![ button("New branch") .icon(lucide!(GitBranch)) .on_press(Message::Pressed), button("Continue") .trailing_icon(lucide!(ArrowRight)) .variant(Variant::Outline) .on_press(Message::Pressed), button("Delete") .icon(lucide!(Trash)) .variant(Variant::Destructive) .on_press(Message::Pressed), ] .spacing(8) .wrap() .into() } } ``` ### Enabled and disabled A button without a message renders disabled. ```rust use iced::widget::{column, row, text}; use iced::{Alignment, Element}; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; #[derive(Debug, Clone)] pub enum Message { Increment, Reset, } #[derive(Debug, Default)] pub struct Example { count: u32, } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Increment => self.count += 1, Message::Reset => self.count = 0, } } pub fn view(&self) -> Element<'_, Message> { let reset = (self.count > 0).then_some(Message::Reset); column![ text(format!("Pressed {} times", self.count)).size(14), row![ button("Press me") .icon(lucide!(Plus)) .on_press(Message::Increment), button("Reset") .variant(Variant::Secondary) .on_press_maybe(reset), ] .spacing(8), ] .spacing(12) .align_x(Alignment::Center) .into() } } ``` ## API - `button(label)`: Creates a text button. It renders disabled until it has a message. - `.variant(Variant)`: Primary, Secondary, Destructive, Outline, Ghost or Link. Defaults to Primary. Link has no side padding, so it lines up with nearby text. - `.size(Size)`: Sm, Md or Lg. Defaults to Md. The square Icon size is deprecated; use icon_button instead. - `.icon(glyph) / .trailing_icon(glyph)`: Adds a Lucide icon before or after the label. - `.on_press(message) / .on_press_maybe(option)`: Sets the message emitted on press. None disables the button. - `.width(length)`: Overrides the width. Buttons shrink to fit by default. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/icon-button.md - https://sam-ruff.github.io/iced-cube/docs/components/tooltip.md --- # Icon Button > A square button that shows only an icon, with a label and an optional pressed state. ```rust use iced_cube::{icon_button, lucide}; use iced_cube::primitives::button::Size; use iced_cube::primitives::icon_button::Variant; ``` ## Usage Give every icon button a label. The icon alone rarely says enough, and the label shows as a tooltip so people can check what a button does before they press it. iced 0.14 has no screen reader support, so the label cannot reach assistive technology yet. A pressed icon button is a toggle: the app holds the on or off value and passes it to `.pressed`, and the button's message flips it. Use pressed buttons for formatting in a toolbar, or for a group where exactly one option is on, such as text alignment. Icon buttons share their sizes and colours with [Button](https://sam-ruff.github.io/iced-cube/docs/components/button.md). There is no Link variant, because a link needs text to read as one; on an icon alone it would look the same as Ghost. The `icon-button` feature turns on `button` and `tooltip`. A disabled icon button draws its icon at half opacity, like the label of a disabled text button. ## Examples ### Formatting toolbar Toggles that stay pressed while a format is on, with labels shown as tooltips. ```rust use iced::widget::{column, container, row, text}; use iced::{Alignment, Element, Length}; use iced_cube::{Glyph, icon_button, lucide, vertical_separator}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Align { Start, Center, End, } #[derive(Debug, Clone)] pub enum Message { ToggleBold, ToggleItalic, ToggleUnderline, Align(Align), } #[derive(Debug)] pub struct Example { bold: bool, italic: bool, underline: bool, align: Align, } impl Default for Example { fn default() -> Self { Self { bold: true, italic: false, underline: false, align: Align::Start, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::ToggleBold => self.bold = !self.bold, Message::ToggleItalic => self.italic = !self.italic, Message::ToggleUnderline => self.underline = !self.underline, Message::Align(align) => self.align = align, } } pub fn view(&self) -> Element<'_, Message> { let toggle = |glyph: Glyph, label: &'static str, on: bool, message: Message| { icon_button(glyph) .label(label) .pressed(on) .on_press(message) }; let align = |glyph: Glyph, label: &'static str, align: Align| { toggle(glyph, label, self.align == align, Message::Align(align)) }; let toolbar = row![ toggle(lucide!(Bold), "Bold", self.bold, Message::ToggleBold), toggle( lucide!(Italic), "Italic", self.italic, Message::ToggleItalic ), toggle( lucide!(Underline), "Underline", self.underline, Message::ToggleUnderline ), container(vertical_separator()).height(20), align(lucide!(TextAlignStart), "Align left", Align::Start), align(lucide!(TextAlignCenter), "Align centre", Align::Center), align(lucide!(TextAlignEnd), "Align right", Align::End), ] .spacing(4) .align_y(Alignment::Center); let on: Vec<&str> = [ (self.bold, "bold"), (self.italic, "italic"), (self.underline, "underline"), ] .into_iter() .filter_map(|(on, name)| on.then_some(name)) .collect(); let status = match on.as_slice() { [] => String::from("Plain text"), names => format!("Formatting: {}", names.join(", ")), }; column![toolbar, text(status).size(14)] .spacing(12) .width(Length::Fixed(260.0)) .into() } } ``` ### Variants The same variants as a text button. ```rust use iced::Element; use iced::widget::row; use iced_cube::primitives::icon_button::Variant; use iced_cube::{icon_button, lucide}; #[derive(Debug, Clone)] pub enum Message { Pressed(Variant), } #[derive(Debug, Default)] pub struct Example { last: Option, } impl Example { pub fn update(&mut self, message: Message) { let Message::Pressed(variant) = message; self.last = Some(variant); } pub fn view(&self) -> Element<'_, Message> { row(Variant::ALL.map(|variant| { icon_button(lucide!(Heart)) .label(format!("{variant:?}")) .variant(variant) .on_press(Message::Pressed(variant)) .into() })) .spacing(8) .into() } } ``` ### Sizes Small, medium and large squares. ```rust use iced::widget::row; use iced::{Alignment, Element}; use iced_cube::primitives::button::Size; use iced_cube::primitives::icon_button::Variant; use iced_cube::{icon_button, lucide}; #[derive(Debug, Clone)] pub enum Message { Pressed, } #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { row([Size::Sm, Size::Md, Size::Lg].map(|size| { icon_button(lucide!(Settings)) .label(format!("Settings ({size:?})")) .variant(Variant::Outline) .size(size) .on_press(Message::Pressed) .into() })) .spacing(8) .align_y(Alignment::Center) .into() } } ``` ### Enabled and disabled Undo and redo turn off when there is nothing to undo or redo. A disabled icon is drawn at half opacity. ```rust use iced::widget::{row, text}; use iced::{Alignment, Element, Length}; use iced_cube::{button, icon_button, lucide}; #[derive(Debug, Clone)] pub enum Message { Edit, Undo, Redo, } #[derive(Debug)] pub struct Example { done: u32, undone: u32, } impl Default for Example { fn default() -> Self { Self { done: 2, undone: 0 } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Edit => { self.done += 1; self.undone = 0; } Message::Undo => { self.done -= 1; self.undone += 1; } Message::Redo => { self.undone -= 1; self.done += 1; } } } pub fn view(&self) -> Element<'_, Message> { let edits = match self.done { 1 => String::from("1 edit"), count => format!("{count} edits"), }; row![ icon_button(lucide!(Undo2)) .label("Undo") .on_press_maybe((self.done > 0).then_some(Message::Undo)), icon_button(lucide!(Redo2)) .label("Redo") .on_press_maybe((self.undone > 0).then_some(Message::Redo)), button("Make an edit").on_press(Message::Edit), text(edits).size(14), ] .spacing(8) .align_y(Alignment::Center) .width(Length::Fixed(300.0)) .into() } } ``` ## API - `icon_button(glyph)`: Creates a square ghost button showing a Lucide icon. It renders disabled until it has a message. - `.label(text)`: Names the action, such as "Bold". The label shows as a tooltip on hover. - `.tooltip(Option)`: Where the label's tooltip appears, or None to never show it. Defaults to Some(Position::Top). - `.pressed(bool)`: Draws the button as switched on, for toolbar toggles. Pass the value your app holds. - `.variant(Variant)`: Primary, Secondary, Destructive, Outline or Ghost: the text button variants without Link. Defaults to Ghost. - `.size(Size)`: The button Size: Sm is 32 pixels square, Md is 36 and Lg is 40. Defaults to Md. - `.on_press(message) / .on_press_maybe(option)`: Sets the message emitted on press. None disables the button. - `.id(id)`: Sets a widget id, for clicking the button in tests. - `colours(tokens, variant, pressed, status) / style(...)`: The resolved colours and iced style, for reuse in custom widgets. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/button.md - https://sam-ruff.github.io/iced-cube/docs/components/tooltip.md --- # Toggle > A button that stays pressed while an option is on. ```rust use iced_cube::{lucide, toggle}; use iced_cube::primitives::toggle::{Size, Variant}; ``` ## Usage A toggle is a button with an on state, for options that sit among other controls rather than in a form: showing a grid, pinning a panel, bold text. The app holds the bool, passes it to `.pressed`, and flips it in the `on_toggle` message, as with a switch. Use a [switch](https://sam-ruff.github.io/iced-cube/docs/components/switch.md) for a setting that takes effect in a settings list, and a toggle where the option lives next to the content it changes. For an option with only an icon, such as bold in a compact toolbar, use a pressed [icon button](https://sam-ruff.github.io/iced-cube/docs/components/icon-button.md), which shows its label as a tooltip. Shortcuts are app-wide, so each toggle needs its own chord. The keyboard example clears the default Space binding and binds Alt+B and Alt+I instead. ## Examples ### Default Toggles with icons that stay pressed while an option is on. ```rust use iced::widget::{column, row, text}; use iced::{Element, Length}; use iced_cube::lucide; use iced_cube::primitives::toggle; #[derive(Debug, Clone)] pub enum Message { Grid(bool), Rulers(bool), Snap(bool), } #[derive(Debug)] pub struct Example { grid: bool, rulers: bool, snap: bool, } impl Default for Example { fn default() -> Self { Self { grid: true, rulers: false, snap: false, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Grid(on) => self.grid = on, Message::Rulers(on) => self.rulers = on, Message::Snap(on) => self.snap = on, } } pub fn view(&self) -> Element<'_, Message> { let toggles = row![ toggle("Grid") .icon(lucide!(Grid3x3)) .pressed(self.grid) .on_toggle(Message::Grid), toggle("Rulers") .icon(lucide!(Ruler)) .pressed(self.rulers) .on_toggle(Message::Rulers), toggle("Snap to grid") .icon(lucide!(Magnet)) .pressed(self.snap) .on_toggle(Message::Snap), ] .spacing(4) .wrap(); let showing: Vec<&str> = [(self.grid, "grid"), (self.rulers, "rulers")] .into_iter() .filter_map(|(on, name)| on.then_some(name)) .collect(); let status = match showing.as_slice() { [] => String::from("Showing the canvas only"), names => format!("Showing the {}", names.join(" and ")), }; column![toggles, text(status).size(14)] .spacing(12) .width(Length::Fill) .max_width(340) .into() } } ``` ### Variants Default and outline toggles, each next to a disabled toggle that is on. ```rust use iced::widget::{column, row, text}; use iced::{Alignment, Element}; use iced_cube::lucide; use iced_cube::primitives::toggle::{Variant, toggle}; #[derive(Debug, Clone)] pub enum Message { Pinned(bool), } #[derive(Debug, Default)] pub struct Example { pinned: bool, } impl Example { pub fn update(&mut self, message: Message) { let Message::Pinned(pinned) = message; self.pinned = pinned; } pub fn view(&self) -> Element<'_, Message> { let line = |variant: Variant| { row![ text(format!("{variant:?}")).size(14).width(64), toggle("Pin") .icon(lucide!(Pin)) .variant(variant) .pressed(self.pinned) .on_toggle(Message::Pinned), toggle("Locked") .icon(lucide!(Lock)) .variant(variant) .pressed(true), ] .spacing(8) .align_y(Alignment::Center) }; column![line(Variant::Default), line(Variant::Outline)] .spacing(16) .into() } } ``` ### Keyboard shortcuts Each toggle's keymap swaps the default Space for its own chord. ```rust use iced::widget::{column, row, text}; use iced::{Element, Length, Subscription}; use iced_cube::keys::{self, Chord, Keymap}; use iced_cube::lucide; use iced_cube::primitives::toggle::{self, Action, toggle}; #[derive(Debug, Clone)] pub enum Message { Bold(bool), Italic(bool), Key(keys::Event), } #[derive(Debug)] pub struct Example { bold: bool, italic: bool, bold_keys: Keymap, italic_keys: Keymap, } impl Default for Example { fn default() -> Self { // Each toggle gets its own chord in place of the default Space. let only = |chord: Chord| toggle::default_keymap().clear().bind(chord, Action::Toggle); Self { bold: false, italic: false, bold_keys: only(Chord::character('b').alt()), italic_keys: only(Chord::character('i').alt()), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Bold(on) => self.bold = on, Message::Italic(on) => self.italic = on, Message::Key(key) => { if let Some(action) = self.bold_keys.resolve_event(&key) { self.bold = action.apply(self.bold); } if let Some(action) = self.italic_keys.resolve_event(&key) { self.italic = action.apply(self.italic); } } } } pub fn view(&self) -> Element<'_, Message> { column![ row![ toggle("Bold") .icon(lucide!(Bold)) .pressed(self.bold) .on_toggle(Message::Bold), toggle("Italic") .icon(lucide!(Italic)) .pressed(self.italic) .on_toggle(Message::Italic), ] .spacing(4), text("Alt+B and Alt+I switch the toggles from anywhere.").size(14), ] .spacing(12) .width(Length::Fill) .max_width(360) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `toggle(label)`: Creates a toggle with a text label, switched off. It renders disabled until it has a message. - `.pressed(bool)`: Whether the toggle is on. Pass the value your app holds. - `.on_toggle(f) / .on_toggle_maybe(option)`: Sets the message for a click. The closure gets the new pressed state. None disables the toggle. - `.icon(glyph)`: Adds a Lucide icon before the label. - `.variant(Variant)`: Default, transparent until hovered, or Outline, with a border. Defaults to Default. - `.size(Size)`: Sm, Md or Lg: 32, 36 or 40 pixels tall. Defaults to Md. - `toggle::default_keymap()`: The default shortcuts as a Keymap. Give each toggle its own chord by clearing the map and binding one. - `Action::Toggle.apply(pressed)`: The pressed state a click would send. Pass it to your on_toggle message. - `colours(tokens, pressed, status) / style(tokens, variant, pressed, status)`: The resolved colours and iced style, for reuse in custom widgets. ## Keyboard - Space: `Toggle`. Switches the toggle on or off. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/toggle-group.md - https://sam-ruff.github.io/iced-cube/docs/components/icon-button.md - https://sam-ruff.github.io/iced-cube/docs/components/switch.md --- # Toggle group > A row of toggles where one, or several, can be on. ```rust use iced_cube::toggle_group; use iced_cube::primitives::toggle_group::{Event, State, Variant, item}; ``` ## Usage The app owns a `toggle_group::State` and routes events into it, as with [tabs](https://sam-ruff.github.io/iced-cube/docs/components/tabs.md). In single mode pressing an item turns the others off; pressing the one that is on turns it off too, unless the group is `.required(true)`, which suits a choice that always has an answer, such as text alignment or a date range. Multiple mode suits formatting options and filters, where each item is independent. `selected()` returns the items that are on in the order they are shown, whatever order they were pressed in. Separate toggles wrap onto new lines on a narrow screen. An outlined group stays in one row, so keep it to two to four short items. Use a single-choice group for a few short options shown side by side, and a [select](https://sam-ruff.github.io/iced-cube/docs/components/select.md) when there are more than four or five. When the options switch between whole views rather than settings, tabs are the clearer choice. The arrow keys are bound by default because a single-choice group behaves like tabs. They are app-wide, so an app showing tabs, a slider or another group on the same screen should rebind one of them. ## Examples ### Single choice An outlined group where one alignment is always on. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::lucide; use iced_cube::primitives::toggle_group::{self, State, Variant, item, toggle_group}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Align { Left, Centre, Right, } #[derive(Debug, Clone)] pub enum Message { Align(toggle_group::Event), } #[derive(Debug)] pub struct Example { align: State, } impl Default for Example { fn default() -> Self { let align = State::single([ item(Align::Left, "Left").icon(lucide!(TextAlignStart)), item(Align::Centre, "Centre").icon(lucide!(TextAlignCenter)), item(Align::Right, "Right").icon(lucide!(TextAlignEnd)), ]); Self { align: align.required(true), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Align(event) = message; let _ = self.align.update(event); } pub fn view(&self) -> Element<'_, Message> { let align = self .align .selected_one() .map_or(String::new(), |align| format!("Text aligned: {align:?}")); column![ toggle_group(&self.align) .variant(Variant::Outline) .on_event(Message::Align), text(align).size(14), ] .spacing(12) .width(Length::Fill) .max_width(320) .into() } } ``` ### Multiple choice Formatting toggles that switch on and off independently, with a disabled item. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::lucide; use iced_cube::primitives::toggle_group::{self, State, item, toggle_group}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Format { Bold, Italic, Underline, Code, } #[derive(Debug, Clone)] pub enum Message { Format(toggle_group::Event), } #[derive(Debug)] pub struct Example { format: State, } impl Default for Example { fn default() -> Self { let format = State::multiple([ item(Format::Bold, "Bold").icon(lucide!(Bold)), item(Format::Italic, "Italic").icon(lucide!(Italic)), item(Format::Underline, "Underline").icon(lucide!(Underline)), item(Format::Code, "Code") .icon(lucide!(Code)) .disabled(true), ]); Self { format: format.with_selected([Format::Bold]), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Format(event) = message; let _ = self.format.update(event); } pub fn view(&self) -> Element<'_, Message> { let on: Vec = self .format .selected() .iter() .map(|format| format!("{format:?}").to_lowercase()) .collect(); let status = match on.as_slice() { [] => String::from("Plain text"), names => format!("Formatting: {}", names.join(", ")), }; column![ toggle_group(&self.format).on_event(Message::Format), text(status).size(14), ] .spacing(12) .width(Length::Fill) .max_width(420) .into() } } ``` ### Keyboard shortcuts The arrow keys move a single-choice group's selection through the default keymap. ```rust use iced::widget::{column, text}; use iced::{Element, Length, Subscription}; use iced_cube::keys::{self, Keymap}; use iced_cube::primitives::toggle_group::{ self, Action, Event, State, Variant, item, toggle_group, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Range { Day, Week, Month, Year, } #[derive(Debug, Clone)] pub enum Message { Range(Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { range: State, keymap: Keymap, } impl Default for Example { fn default() -> Self { let range = State::single([ item(Range::Day, "Day"), item(Range::Week, "Week"), item(Range::Month, "Month"), item(Range::Year, "Year"), ]); Self { range: range.with_selected([Range::Week]).required(true), keymap: toggle_group::default_keymap(), } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Range(event) => Some(event), Message::Key(key) => self .keymap .resolve_event(&key) .and_then(|action| action.event(&self.range)), }; if let Some(event) = event { let _ = self.range.update(event); } } pub fn view(&self) -> Element<'_, Message> { column![ toggle_group(&self.range) .variant(Variant::Outline) .on_event(Message::Range), text("The left and right arrow keys move the selection.").size(14), ] .spacing(12) .width(Length::Fill) .max_width(360) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `State::single(items) / State::multiple(items)`: Holds the items, with nothing on. Single mode keeps at most one item on; multiple mode switches each one by itself. - `.required(true)`: Single mode only: the last item on stays on, and the first enabled item starts on when nothing else is. - `.with_selected(ids)`: Switches these items on, skipping unknown and disabled ones. In single mode the first that applies replaces the selection. - `item(id, label)`: Creates an item. Add .icon(glyph) or .disabled(true). - `state.update(Event)`: Toggle(id), Next or Previous. Returns the ids that are on, if anything changed. - `state.selected() / state.selected_one() / state.is_selected(id)`: The ids that are on in item order, the first of them, and a check for one id. - `state.set_disabled(id, bool)`: Enables or disables an item at runtime. Whether it is on stays as it was. - `toggle_group(&state)`: Renders the items. They render disabled until .on_event is set. - `.variant(Variant)`: Default, separate toggles with a small gap, or Outline, attached segments inside one border. Defaults to Default. - `.size(Size)`: The toggle Size: Sm, Md or Lg. Defaults to Md. - `.on_event(f)`: Maps item events to your message, such as Message::Group. - `toggle_group::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.event(&state)`: Turns a resolved Action (Next or Previous) into the Event to pass to state.update. Multiple-mode groups get None. ## Keyboard - ArrowRight: `Next`. Turns on the next enabled item of a single-choice group, wrapping. - ArrowLeft: `Previous`. Turns on the previous enabled item of a single-choice group, wrapping. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/toggle.md - https://sam-ruff.github.io/iced-cube/docs/components/tabs.md - https://sam-ruff.github.io/iced-cube/docs/components/radio.md --- # Input > A single line of editable text, with an optional icon, password masking and an invalid state. ```rust use iced_cube::{input, lucide}; use iced_cube::primitives::input::Size; ``` ## Usage Inputs are controlled: the app stores the value, passes it to `input` and updates it from the `on_input` message. Leave out `on_input` to show a read-only value in the disabled style. To name an input, wrap it in [`field`](https://sam-ruff.github.io/iced-cube/docs/components/field.md), which adds a label, a description and an error message. Set `.invalid(true)` on the input when the field has an error so the border matches. Clicking an input focuses it. iced does not move focus on Tab by itself: listen for Tab in a keyboard subscription and return `iced::widget::operation::focus_next()`, or give the input an `.id(...)` and return `iced::widget::operation::focus(id)` from `update`. ## Examples ### Default A controlled input that submits on Enter. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::input; use iced_cube::theme::{Tokens, text_size}; #[derive(Debug, Clone)] pub enum Message { NameChanged(String), Submit, } #[derive(Debug, Default)] pub struct Example { name: String, greeting: Option, } impl Example { pub fn update(&mut self, message: Message) { match message { Message::NameChanged(name) => self.name = name, Message::Submit => self.greeting = Some(format!("Hello, {}!", self.name.trim())), } } pub fn view(&self) -> Element<'_, Message> { let hint = self.greeting.as_deref().unwrap_or("Press Enter to submit."); column![ input("Your name", &self.name) .on_input(Message::NameChanged) .on_submit(Message::Submit), text(hint).size(text_size::SM).style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .spacing(12) .width(Length::Fixed(320.0)) .into() } } ``` ### With icon A leading Lucide icon, in small, medium and large sizes. ```rust use iced::widget::column; use iced::{Element, Length}; use iced_cube::primitives::input::Size; use iced_cube::{input, lucide}; #[derive(Debug, Clone)] pub enum Message { Search(String), Email(String), Website(String), } #[derive(Debug, Default)] pub struct Example { search: String, email: String, website: String, } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Search(value) => self.search = value, Message::Email(value) => self.email = value, Message::Website(value) => self.website = value, } } pub fn view(&self) -> Element<'_, Message> { column![ input("Search", &self.search) .icon(lucide!(Search)) .size(Size::Sm) .on_input(Message::Search), input("Email", &self.email) .icon(lucide!(Mail)) .on_input(Message::Email), input("Website", &self.website) .icon(lucide!(Globe)) .size(Size::Lg) .on_input(Message::Website), ] .spacing(12) .width(Length::Fixed(320.0)) .into() } } ``` ### Password A secure input with a button that reveals the value. ```rust use iced::widget::row; use iced::{Alignment, Element, Length}; use iced_cube::primitives::icon_button::Variant; use iced_cube::{icon_button, input, lucide}; #[derive(Debug, Clone)] pub enum Message { PasswordChanged(String), ToggleVisibility, } #[derive(Debug, Default)] pub struct Example { password: String, visible: bool, } impl Example { pub fn update(&mut self, message: Message) { match message { Message::PasswordChanged(password) => self.password = password, Message::ToggleVisibility => self.visible = !self.visible, } } pub fn view(&self) -> Element<'_, Message> { let toggle = if self.visible { lucide!(EyeOff) } else { lucide!(Eye) }; row![ input("Password", &self.password) .icon(lucide!(LockKeyhole)) .secure(!self.visible) .on_input(Message::PasswordChanged), icon_button(toggle) .variant(Variant::Outline) .on_press(Message::ToggleVisibility), ] .spacing(8) .align_y(Alignment::Center) .width(Length::Fixed(320.0)) .into() } } ``` ### Invalid and disabled An invalid value draws a destructive border. An input without on_input is disabled. ```rust use iced::widget::column; use iced::{Element, Length}; use iced_cube::{input, lucide}; #[derive(Debug, Clone)] pub enum Message { UsernameChanged(String), } #[derive(Debug)] pub struct Example { username: String, } impl Default for Example { fn default() -> Self { Self { username: "ada lovelace".into(), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::UsernameChanged(username) = message; self.username = username; } pub fn view(&self) -> Element<'_, Message> { let invalid = self.username.contains(char::is_whitespace); column![ input("Username", &self.username) .icon(lucide!(AtSign)) .invalid(invalid) .on_input(Message::UsernameChanged), input("Workspace", "acme-corp").icon(lucide!(Building)), ] .spacing(12) .width(Length::Fixed(320.0)) .into() } } ``` ## API - `input(placeholder, value)`: Creates a text input showing value, with the placeholder while it is empty. It renders disabled until it has on_input. - `.on_input(fn) / .on_input_maybe(option)`: Sets the message built from the new value on every edit. None disables the input. - `.on_submit(message) / .on_submit_maybe(option)`: Sets the message emitted when Enter is pressed. A disabled input never submits. - `.icon(glyph)`: Adds a Lucide icon at the start of the input. - `.secure(bool)`: Masks the value, for passwords and other secrets. - `.invalid(bool)`: Draws a destructive border. Pair it with an error message from field. - `.size(Size)`: Sm, Md or Lg (32, 36 and 40 pixels tall). Defaults to Md. - `.id(id) / .width(length)`: Sets the widget id, used to focus the input from a task or find it in tests, and overrides the width. Inputs fill the available width by default. ## Keyboard - Enter: Emits the on_submit message. - Escape: Removes focus from the input. - Home / End: Moves the cursor to the start or end of the value. - Shift + Arrow: Extends the selection. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/field.md - https://sam-ruff.github.io/iced-cube/docs/components/textarea.md --- # Textarea > Multi-line text editing that matches the look of Input. ```rust use iced::widget::text_editor; use iced_cube::textarea; ``` ## Usage The textarea wraps iced's text editor, so your app owns the `text_editor::Content` and applies every `Action` it receives. This keeps undo, validation and limits in your own `update`, as the character count example shows. Read the text back with `content.text()`. Wrap the textarea in [`field`](https://sam-ruff.github.io/iced-cube/docs/components/field.md) to give it a label and helper text. ## Examples ### Default Grows with its content, with a character count below. ```rust use iced::widget::text_editor::{Action, Content}; use iced::widget::{column, text}; use iced::{Alignment, Element, Length}; use iced_cube::textarea; use iced_cube::theme::{Tokens, text_size}; const LIMIT: usize = 280; #[derive(Debug, Clone)] pub enum Message { Edit(Action), } #[derive(Debug, Default)] pub struct Example { content: Content, } impl Example { pub fn update(&mut self, message: Message) { let Message::Edit(action) = message; self.content.perform(action); } pub fn view(&self) -> Element<'_, Message> { let count = self.content.text().trim_end_matches('\n').chars().count(); column![ textarea(&self.content) .placeholder("Type your message here.") .invalid(count > LIMIT) .on_action(Message::Edit), text(format!("{count} / {LIMIT}")) .size(text_size::XS) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .spacing(8) .align_x(Alignment::End) .width(Length::Fixed(360.0)) .into() } } ``` ### Fixed height and disabled A fixed-height textarea next to one without on_action, which is disabled. ```rust use iced::widget::row; use iced::widget::text_editor::{Action, Content}; use iced::{Element, Length}; use iced_cube::textarea; #[derive(Debug, Clone)] pub enum Message { Edit(Action), } #[derive(Debug)] pub struct Example { draft: Content, archived: Content, } impl Default for Example { fn default() -> Self { Self { draft: Content::with_text("Fixed height, scrolls once the text overflows."), archived: Content::with_text("This note is archived and can no longer be edited."), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Edit(action) = message; self.draft.perform(action); } pub fn view(&self) -> Element<'_, Message> { row![ textarea(&self.draft).height(120).on_action(Message::Edit), textarea(&self.archived).height(120), ] .spacing(16) .width(Length::Fixed(520.0)) .into() } } ``` ## API - `textarea(&content)`: Creates a textarea editing a text_editor::Content owned by your app. It renders disabled until it has on_action. - `.on_action(fn) / .on_action_maybe(option)`: Sets the message built from each edit action. Apply it with content.perform(action) in update. None disables the textarea. - `.placeholder(text)`: Text shown while the content is empty. - `.height(length)`: Sets the height. By default the textarea starts about three lines tall and grows with its content. A fixed height scrolls once the text overflows. - `.min_height(pixels)`: Sets the starting height used while the height is Shrink. Defaults to 80. - `.invalid(bool)`: Draws a destructive border. - `.id(id) / .width(length)`: Sets the widget id and overrides the width. Textareas fill the available width by default. ## Keyboard - Enter: Inserts a new line. - Escape: Removes focus from the textarea. - Arrow keys, Home, End, Page Up, Page Down: Moves the cursor. Hold Shift to select. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/input.md - https://sam-ruff.github.io/iced-cube/docs/components/field.md --- # Checkbox > Turns an option on or off, with an indeterminate state for mixed selections. ```rust use iced_cube::checkbox; use iced_cube::primitives::checkbox::CheckState; ``` ## Usage Use a checkbox for independent options that take effect when a form is submitted. For a setting that applies straight away, a switch reads better. The indeterminate state is for a parent box whose children are partly selected. The app works out the parent's state from its children, as the select-all example shows, and a click on an indeterminate box always checks it. ## Examples ### Default A labelled checkbox bound to a bool. ```rust use iced::Element; use iced_cube::primitives::checkbox; #[derive(Debug, Clone)] pub enum Message { Accept(bool), } #[derive(Debug, Default)] pub struct Example { accepted: bool, } impl Example { pub fn update(&mut self, message: Message) { let Message::Accept(accepted) = message; self.accepted = accepted; } pub fn view(&self) -> Element<'_, Message> { checkbox(self.accepted) .label("Accept terms and conditions") .on_toggle(Message::Accept) .into() } } ``` ### States Unchecked, checked and indeterminate, enabled and disabled. ```rust use iced::Element; use iced::widget::{column, row}; use iced_cube::primitives::checkbox; use iced_cube::primitives::checkbox::CheckState; #[derive(Debug, Clone)] pub enum Message { Toggled, } #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let enabled = column![ checkbox(CheckState::Unchecked) .label("Unchecked") .on_toggle(|_| Message::Toggled), checkbox(CheckState::Checked) .label("Checked") .on_toggle(|_| Message::Toggled), checkbox(CheckState::Indeterminate) .label("Indeterminate") .on_toggle(|_| Message::Toggled), ] .spacing(12); let disabled = column![ checkbox(CheckState::Unchecked).label("Disabled"), checkbox(CheckState::Checked).label("Disabled"), checkbox(CheckState::Indeterminate).label("Disabled"), ] .spacing(12); row![enabled, disabled].spacing(48).into() } } ``` ### Select all A parent checkbox that turns indeterminate for a mixed selection. ```rust use iced::widget::{column, container}; use iced::{Element, padding}; use iced_cube::primitives::checkbox; use iced_cube::primitives::checkbox::CheckState; const TOPPINGS: [&str; 3] = ["Mushrooms", "Olives", "Peppers"]; #[derive(Debug, Clone)] pub enum Message { All(bool), Topping(usize, bool), } #[derive(Debug)] pub struct Example { chosen: [bool; 3], } impl Default for Example { fn default() -> Self { Self { chosen: [true, false, true], } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::All(checked) => self.chosen = [checked; 3], Message::Topping(index, checked) => self.chosen[index] = checked, } } fn all(&self) -> CheckState { match self.chosen.iter().filter(|chosen| **chosen).count() { 0 => CheckState::Unchecked, n if n == self.chosen.len() => CheckState::Checked, _ => CheckState::Indeterminate, } } pub fn view(&self) -> Element<'_, Message> { let toppings = column(TOPPINGS.iter().enumerate().map(|(index, name)| { checkbox(self.chosen[index]) .label(*name) .on_toggle(move |checked| Message::Topping(index, checked)) .into() })) .spacing(12); column![ checkbox(self.all()) .label("All toppings") .on_toggle(Message::All), container(toppings).padding(padding::left(24)), ] .spacing(12) .into() } } ``` ## API - `checkbox(state)`: Creates a checkbox from a bool or a CheckState. It renders disabled until it has a message. - `.label(text)`: Adds a label after the box. Clicking the label toggles the checkbox too. - `.on_toggle(f) / .on_toggle_maybe(option)`: Sets the message for a click. The closure gets the new checked value, so an indeterminate box reports true. None disables the checkbox. - `CheckState`: Unchecked, Checked or Indeterminate. toggled() gives the state after a click. - `checkbox::default_keymap()`: The default shortcuts as a Keymap, for apps that track a current checkbox. - `Action::Toggle.apply(state)`: The checked value a click would send. Pass it to your on_toggle message. ## Keyboard - Space: `Toggle`. Checks or unchecks the current checkbox. An indeterminate box becomes checked. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/switch.md - https://sam-ruff.github.io/iced-cube/docs/components/radio.md --- # Radio > Picks exactly one option from a small, visible set. ```rust use iced_cube::{radio, radio_group}; use iced_cube::primitives::radio::Direction; ``` ## Usage Use radios when there are only a handful of options and seeing them all at once helps the choice. For longer lists, a select takes less room. Options are usually a small `Copy` enum with a `Display` implementation. Clicking a radio or its label emits its value; the app stores it and passes it back as `selected`. ## Examples ### Default A vertical group built from an enum's options. ```rust use std::fmt; use iced::Element; use iced::widget::{column, text}; use iced_cube::primitives::radio_group; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Density { Comfortable, Default, Compact, } impl fmt::Display for Density { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Density::Comfortable => "Comfortable", Density::Default => "Default", Density::Compact => "Compact", }) } } #[derive(Debug, Clone)] pub enum Message { Selected(Density), } #[derive(Debug)] pub struct Example { density: Density, } impl Default for Example { fn default() -> Self { Self { density: Density::Default, } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Selected(density) = message; self.density = density; } pub fn view(&self) -> Element<'_, Message> { column![ radio_group( [Density::Comfortable, Density::Default, Density::Compact], Some(self.density), ) .on_select(Message::Selected), text(format!("Selected: {}", self.density)).size(14), ] .spacing(20) .width(220) .into() } } ``` ### Horizontal The same group laid out in a row. ```rust use iced::Element; use iced_cube::primitives::radio::Direction; use iced_cube::primitives::radio_group; #[derive(Debug, Clone)] pub enum Message { Selected(&'static str), } #[derive(Debug)] pub struct Example { size: &'static str, } impl Default for Example { fn default() -> Self { Self { size: "Medium" } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Selected(size) = message; self.size = size; } pub fn view(&self) -> Element<'_, Message> { radio_group(["Small", "Medium", "Large"], Some(self.size)) .direction(Direction::Horizontal) .on_select(Message::Selected) .into() } } ``` ### Single radios Individual radios, one of them disabled. ```rust use iced::Element; use iced::widget::column; use iced_cube::primitives::radio; #[derive(Debug, Clone)] pub enum Message { Selected(u8), } #[derive(Debug)] pub struct Example { plan: u8, } impl Default for Example { fn default() -> Self { Self { plan: 1 } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Selected(plan) = message; self.plan = plan; } pub fn view(&self) -> Element<'_, Message> { let selected = Some(self.plan); column![ radio("Starter", 1, selected).on_select(Message::Selected), radio("Professional", 2, selected).on_select(Message::Selected), radio("Enterprise (contact sales)", 3, selected), ] .spacing(12) .into() } } ``` ## API - `radio_group(options, selected)`: Creates one radio per option, labelled with its Display text. It renders disabled until it has a message. - `.on_select(f)`: Sets the message emitted with the option that was clicked. - `.direction(Direction)`: Vertical or Horizontal. Defaults to Vertical. Horizontal groups wrap when space runs out. - `radio(label, value, selected)`: Creates a single radio for building custom layouts. It is selected when selected holds its value. - `radio(...).on_select(f)`: Sets the message for a click, built from the radio's value. Without it the radio is disabled. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/select.md - https://sam-ruff.github.io/iced-cube/docs/components/checkbox.md --- # Switch > Turns a setting on or off with immediate effect. ```rust use iced_cube::switch; ``` ## Usage Use a switch for settings that apply as soon as they change, such as turning notifications on. For choices that wait for a submit button, use a checkbox. In a settings list, pass the switch without a label and put the title and description in your own layout, as the settings example does. ## Examples ### Default A labelled switch bound to a bool. ```rust use iced::Element; use iced_cube::primitives::switch; #[derive(Debug, Clone)] pub enum Message { AirplaneMode(bool), } #[derive(Debug, Default)] pub struct Example { airplane_mode: bool, } impl Example { pub fn update(&mut self, message: Message) { let Message::AirplaneMode(on) = message; self.airplane_mode = on; } pub fn view(&self) -> Element<'_, Message> { switch(self.airplane_mode) .label("Airplane mode") .on_toggle(Message::AirplaneMode) .into() } } ``` ### States On and off, enabled and disabled. ```rust use iced::Element; use iced::widget::{column, row}; use iced_cube::primitives::switch; #[derive(Debug, Clone)] pub enum Message { Toggled, } #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let enabled = column![ switch(false).label("Off").on_toggle(|_| Message::Toggled), switch(true).label("On").on_toggle(|_| Message::Toggled), ] .spacing(16); let disabled = column![ switch(false).label("Disabled off"), switch(true).label("Disabled on"), ] .spacing(16); row![enabled, disabled].spacing(48).into() } } ``` ### Settings list Switches in a settings list, one depending on another. ```rust use iced::widget::{column, row, space, text}; use iced::{Alignment, Element, Length, Theme}; use iced_cube::primitives::{Switch, switch}; use iced_cube::theme::Tokens; #[derive(Debug, Clone)] pub enum Message { Notifications(bool), Sounds(bool), } #[derive(Debug)] pub struct Example { notifications: bool, sounds: bool, } impl Default for Example { fn default() -> Self { Self { notifications: true, sounds: false, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Notifications(on) => self.notifications = on, Message::Sounds(on) => self.sounds = on, } } pub fn view(&self) -> Element<'_, Message> { let sounds_enabled = self.notifications.then_some(Message::Sounds); column![ setting( "Notifications", "Show a banner for new messages.", switch(self.notifications).on_toggle(Message::Notifications), ), setting( "Sounds", "Only available while notifications are on.", switch(self.sounds).on_toggle_maybe(sounds_enabled), ), ] .spacing(20) .width(360) .into() } } fn setting<'a>( title: &'a str, detail: &'a str, control: Switch<'a, Message>, ) -> Element<'a, Message> { let detail = text(detail).size(12).style(|theme: &Theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }); row![ column![text(title).size(14), detail].spacing(2), space().width(Length::Fill), control, ] .align_y(Alignment::Center) .into() } ``` ## API - `switch(is_on)`: Creates a switch. It renders disabled until it has a message. - `.label(text)`: Adds a label after the switch. Clicking the label toggles the switch too. - `.on_toggle(f) / .on_toggle_maybe(option)`: Sets the message for a click. The closure gets the new value. None disables the switch. - `switch::default_keymap()`: The default shortcuts as a Keymap, for apps that track a current switch. - `Action::Toggle.apply(is_on)`: The value a click would send. Pass it to your on_toggle message. ## Keyboard - Space: `Toggle`. Turns the current switch on or off. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/checkbox.md --- # Select > Chooses one value from a dropdown list. ```rust use iced_cube::select; ``` ## Usage Use a select when there are too many options to show as radios, or when space is tight. The list opens below the field, as wide as it, with a check mark on the selected option, and looks like every other menu and list. It closes when an option is chosen, on Escape, or when you click elsewhere; that click goes no further. Options can be borrowed, such as a `const` slice, so the list is not copied on every view. ## Examples ### Default A select with a placeholder until a value is chosen. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::forms::select; const TIMEZONES: &[&str] = &[ "London (GMT)", "Paris (CET)", "New York (EST)", "Tokyo (JST)", ]; #[derive(Debug, Clone)] pub enum Message { Selected(&'static str), } #[derive(Debug, Default)] pub struct Example { timezone: Option<&'static str>, } impl Example { pub fn update(&mut self, message: Message) { let Message::Selected(timezone) = message; self.timezone = Some(timezone); } pub fn view(&self) -> Element<'_, Message> { column![ text("Timezone").size(14), select(TIMEZONES, self.timezone) .placeholder("Select a timezone") .width(240) .on_select(Message::Selected), ] .spacing(8) .height(Length::Fill) .into() } } ``` ### States Enum options, with enabled and disabled selects. ```rust use std::fmt; use iced::widget::{container, row}; use iced::{Element, Length}; use iced_cube::forms::select; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Role { Viewer, Editor, Owner, } impl Role { const ALL: [Role; 3] = [Role::Viewer, Role::Editor, Role::Owner]; } impl fmt::Display for Role { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Role::Viewer => "Viewer", Role::Editor => "Editor", Role::Owner => "Owner", }) } } #[derive(Debug, Clone)] pub enum Message { Selected(Role), } #[derive(Debug)] pub struct Example { role: Role, } impl Default for Example { fn default() -> Self { Self { role: Role::Editor } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Selected(role) = message; self.role = role; } pub fn view(&self) -> Element<'_, Message> { container( row![ select(&Role::ALL[..], Some(self.role)) .width(160) .on_select(Message::Selected), select(&Role::ALL[..], None) .placeholder("Disabled") .width(160), select(&Role::ALL[..], Some(Role::Owner)).width(160), ] .spacing(12) .wrap(), ) .height(Length::Fill) .into() } } ``` ## API - `select(options, selected)`: Creates a select over a slice or Vec of options, labelled with their Display text. It renders disabled until it has a message. - `.on_select(f) / .on_select_maybe(option)`: Sets the message emitted with the chosen option. None disables the select. - `.placeholder(text)`: Text shown while nothing is selected. - `.width(length)`: Overrides the width. Defaults to 200 pixels. - `select::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.apply(&options, selected)`: The option a resolved Action (Next or Previous) selects, or None at either end. Send it through your on_select message. ## Keyboard - ArrowDown: `Next`. Selects the next option, stopping at the last. - ArrowUp: `Previous`. Selects the previous option, stopping at the first. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/radio.md - https://sam-ruff.github.io/iced-cube/docs/components/field.md --- # Slider > Chooses a number within a range by dragging a handle. ```rust use iced_cube::slider; ``` ## Usage Sliders work with `f32`, `f64`, `u8`, `u16`, `u32`, `i16` and `i32`: any `Copy` number that converts from `u8` and into `f64` without loss. That rules out `i8`, 64-bit integers and `usize`. Use sliders where an approximate value is fine, such as volume or opacity. When the exact number matters, show the value with `.show_value()` or `.format_value(f)`. iced sliders do not take keyboard focus, so shortcuts are app-wide. With several sliders on screen, the app decides which one is current and applies the resolved action to that value. ## Examples ### Default A labelled slider that shows its value. ```rust use iced::Element; use iced_cube::primitives::slider; #[derive(Debug, Clone)] pub enum Message { Volume(u8), } #[derive(Debug)] pub struct Example { volume: u8, } impl Default for Example { fn default() -> Self { Self { volume: 40 } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Volume(volume) = message; self.volume = volume; } pub fn view(&self) -> Element<'_, Message> { slider(0..=100, self.volume) .label("Volume") .show_value() .width(320) .on_change(Message::Volume) .into() } } ``` ### Steps Snapping to a step, with a custom value format. ```rust use iced::Element; use iced::widget::column; use iced_cube::primitives::slider; #[derive(Debug, Clone)] pub enum Message { Opacity(f32), Quality(f32), } #[derive(Debug)] pub struct Example { opacity: f32, quality: f32, } impl Default for Example { fn default() -> Self { Self { opacity: 0.75, quality: 3.0, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Opacity(opacity) => self.opacity = opacity, Message::Quality(quality) => self.quality = quality, } } pub fn view(&self) -> Element<'_, Message> { column![ slider(0.0..=1.0, self.opacity) .step(0.25) .label("Opacity") .format_value(|value| format!("{:.0}%", value * 100.0)) .on_change(Message::Opacity), slider(1.0..=5.0, self.quality) .step(1.0) .label("Quality") .format_value(|value| format!("{value:.0} of 5")) .on_change(Message::Quality), ] .spacing(24) .width(320) .into() } } ``` ### Keyboard shortcuts Arrow keys, Home and End through the default keymap, plus an added binding. ```rust use iced::widget::{column, text}; use iced::{Element, Subscription}; use iced_cube::keys::{self, Chord, Keymap}; use iced_cube::primitives::slider::{self, Action}; const RANGE: std::ops::RangeInclusive = 0..=100; const STEP: u8 = 5; #[derive(Debug, Clone)] pub enum Message { Volume(u8), Key(keys::Event), } #[derive(Debug)] pub struct Example { volume: u8, keymap: Keymap, } impl Default for Example { fn default() -> Self { Self { volume: 40, keymap: slider::default_keymap().bind(Chord::character('m'), Action::Min), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Volume(volume) => self.volume = volume, Message::Key(key) => { if let Some(action) = self.keymap.resolve_event(&key) { self.volume = action.apply(self.volume, RANGE, Some(STEP)); } } } } pub fn view(&self) -> Element<'_, Message> { column![ slider::slider(RANGE, self.volume) .step(STEP) .label("Volume") .show_value() .width(320) .on_change(Message::Volume), text("Arrow keys step by 5, Home and End jump to the ends, and M mutes.").size(14), ] .spacing(16) .width(320) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ### Disabled A slider without a message renders disabled. ```rust use iced::Element; use iced::widget::column; use iced_cube::primitives::slider; #[derive(Debug, Clone)] pub enum Message { Brightness(u8), } #[derive(Debug)] pub struct Example { brightness: u8, } impl Default for Example { fn default() -> Self { Self { brightness: 70 } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Brightness(brightness) = message; self.brightness = brightness; } pub fn view(&self) -> Element<'_, Message> { column![ slider(0..=100, self.brightness) .label("Brightness") .show_value() .on_change(Message::Brightness), slider(0..=100, 30_u8) .label("Contrast (locked)") .show_value(), slider(0..=100, 55_u8), ] .spacing(24) .width(320) .into() } } ``` ## API - `slider(range, value)`: Creates a slider over an inclusive range. The value is clamped to the range. It renders disabled until it has a message. - `.on_change(f)`: Sets the message emitted with the new value while dragging. - `.on_release(message)`: Sets a message emitted once the handle is released, useful for saving the final value. - `.step(value)`: Snaps the value to multiples of the step. Zero or negative steps are ignored. - `.label(text)`: Shows a label above the slider. - `.show_value() / .format_value(f)`: Shows the current value above the slider, as plain text or formatted by a closure. - `.width(length)`: Overrides the width. Sliders fill the available width by default. - `slider::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.apply(value, range, step)`: The value after an Action (Increase, Decrease, Min or Max), snapped to the step grid. Send it through your on_change message. - `slider::stepped(value, range, step, steps)`: Moves a value by whole steps within a range. Without a valid step it moves by 1, as dragging does. ## Keyboard - ArrowRight or ArrowUp: `Increase`. Raises the value by one step. - ArrowLeft or ArrowDown: `Decrease`. Lowers the value by one step. - Home: `Min`. Sets the value to the start of the range. - End: `Max`. Sets the value to the end of the range. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/progress.md - https://sam-ruff.github.io/iced-cube/docs/components/field.md --- # Field and label > Puts a label above any form control, with helper text or an error message below it. ```rust use iced_cube::{field, label}; use iced_cube::forms::label::State; ``` ## Usage Use `field` for every form control so labels, descriptions and errors line up the same way across your app. The control keeps its own messages; `field` only lays it out. When a field has an error, also mark the control itself as invalid (for example `input(...).invalid(true)`) so its border matches the message. Clicking a label does not focus its control, and iced does not yet expose labels to screen readers, so keep label text short and visible next to the control it names. ## Examples ### Field A label above a control, with a required marker or a description. ```rust use iced::widget::column; use iced::{Element, Length}; use iced_cube::{field, input}; #[derive(Debug, Clone)] pub enum Message { NameChanged(String), EmailChanged(String), } #[derive(Debug, Default)] pub struct Example { name: String, email: String, } impl Example { pub fn update(&mut self, message: Message) { match message { Message::NameChanged(name) => self.name = name, Message::EmailChanged(email) => self.email = email, } } pub fn view(&self) -> Element<'_, Message> { column![ field( "Display name", input("Ada Lovelace", &self.name).on_input(Message::NameChanged), ) .required(true), field( "Email", input("you@example.com", &self.email).on_input(Message::EmailChanged), ) .description("Receipts are sent to this address."), ] .spacing(20) .width(Length::Fixed(320.0)) .into() } } ``` ### Validation An error message below the control turns the label destructive. ```rust use iced::widget::column; use iced::{Element, Length}; use iced_cube::{field, input, lucide}; #[derive(Debug, Clone)] pub enum Message { EmailChanged(String), } #[derive(Debug)] pub struct Example { email: String, } impl Default for Example { fn default() -> Self { Self { email: "ada@".into(), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::EmailChanged(email) = message; self.email = email; } pub fn view(&self) -> Element<'_, Message> { let error = email_error(&self.email); column![ field( "Email", input("you@example.com", &self.email) .icon(lucide!(Mail)) .invalid(error.is_some()) .on_input(Message::EmailChanged), ) .required(true) .error_maybe(error), field("Workspace", input("acme-corp", "")) .description("Only an owner can change the workspace.") .disabled(true), ] .spacing(20) .width(Length::Fixed(320.0)) .into() } } fn email_error(email: &str) -> Option<&'static str> { let Some((user, domain)) = email.split_once('@') else { return Some("An email address needs an @."); }; if user.is_empty() || !domain.contains('.') { return Some("Enter a full address, like ada@example.com."); } None } ``` ## API - `field(label, control)`: Stacks a label above any control, such as an input, textarea or select. - `.description(text)`: Adds muted helper text below the control. - `.error(text) / .error_maybe(option)`: Shows an error below the control and colours the label to match. None clears it. - `.required(bool)`: Adds a required marker after the label. - `.disabled(bool)`: Mutes the label to match a disabled control. - `.width(length)`: Overrides the width of the whole field. Fields fill the available width by default. - `label(text)`: A standalone label, with .required(bool), .disabled(bool), .invalid(bool) and .state(State). - `State`: Normal, Disabled or Invalid. Sets the label's colour. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/input.md - https://sam-ruff.github.io/iced-cube/docs/components/textarea.md - https://sam-ruff.github.io/iced-cube/docs/components/select.md --- # Combobox > A text field with suggestions that filter as you type. ```rust use iced_cube::forms::combobox::{self, State, combobox}; ``` ## Usage Use a combobox when the list is long enough that people would rather type than scroll, such as timezones or countries. For a handful of options, a [select](https://sam-ruff.github.io/iced-cube/docs/components/select.md) is simpler. The app owns a `combobox::State` and passes every event to `update`. Clicking the field or pressing the down arrow opens the list; typing filters it. The selected value shows in the field while the list is closed, with the caret at its end, and as the placeholder while you type. In a [field](https://sam-ruff.github.io/iced-cube/docs/components/field.md) with an error, set `.invalid(true)` so the border matches the message, as the field example does. The list is as wide as the field, draws its rows like every other menu and list, and stays inside the window. The combobox handles its own keys while its field has focus or its list is open, so there is no need to subscribe to key presses. Inside a dialog or popover, Escape closes the list first and leaves the dialog or popover open. The list closes when the field loses focus or when you click elsewhere; that click goes no further and the field gives up focus. Long lists show a window of rows that follows the highlight and scrolls with the mouse wheel. ## Examples ### Default Suggestions filter as you type. The list starts open here so you can see it. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::forms::combobox::{self, Event, State, combobox}; const FRUITS: &[&str] = &[ "Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape", "Lemon", "Mango", "Orange", "Pear", ]; #[derive(Debug, Clone)] pub enum Message { Fruit(combobox::Event), } #[derive(Debug)] pub struct Example { fruit: State<&'static str>, } impl Default for Example { fn default() -> Self { let mut fruit = State::new(FRUITS.iter().copied()).with_visible_rows(5); let _ = fruit.update(Event::Open); Self { fruit } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Fruit(event) = message; let _ = self.fruit.update(event); } pub fn view(&self) -> Element<'_, Message> { let chosen = match self.fruit.selected() { Some(fruit) => format!("Chosen: {fruit}"), None => "Type to filter, or use the arrow keys.".to_owned(), }; column![ text(chosen).size(14), combobox(&self.fruit) .placeholder("Search fruit...") .on_event(Message::Fruit), ] .spacing(12) .width(Length::Fixed(280.0)) .height(Length::Fill) .into() } } ``` ### Empty results A message in the list when nothing matches the query. ```rust use iced::widget::container; use iced::{Element, Length}; use iced_cube::forms::combobox::{self, Event, State, combobox}; const LANGUAGES: &[&str] = &["English", "French", "German", "Italian", "Spanish", "Welsh"]; #[derive(Debug, Clone)] pub enum Message { Language(combobox::Event), } #[derive(Debug)] pub struct Example { language: State<&'static str>, } impl Default for Example { fn default() -> Self { let mut language = State::new(LANGUAGES.iter().copied()); let _ = language.update(Event::Input("Klingon".to_owned())); Self { language } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Language(event) = message; let _ = self.language.update(event); } pub fn view(&self) -> Element<'_, Message> { container( combobox(&self.language) .placeholder("Search languages...") .empty("No language found.") .width(280) .on_event(Message::Language), ) .height(Length::Fill) .into() } } ``` ### In a field A labelled combobox showing its selected value, a required one marked invalid until something is picked, and a disabled one. ```rust use iced::widget::column; use iced::{Element, Length}; use iced_cube::field; use iced_cube::forms::combobox::{self, State, combobox}; const TIMEZONES: &[&str] = &[ "Auckland (NZST)", "Berlin (CET)", "Chicago (CST)", "Dubai (GST)", "Honolulu (HST)", "London (GMT)", "Los Angeles (PST)", "Mumbai (IST)", "New York (EST)", "Paris (CET)", "Singapore (SGT)", "Sydney (AEST)", "Tokyo (JST)", ]; const OFFICES: &[&str] = &["Edinburgh", "Leeds", "London", "Manchester"]; #[derive(Debug, Clone)] pub enum Message { Timezone(combobox::Event), Office(combobox::Event), } #[derive(Debug)] pub struct Example { timezone: State<&'static str>, office: State<&'static str>, region: State<&'static str>, } impl Default for Example { fn default() -> Self { Self { timezone: State::new(TIMEZONES.iter().copied()).with_selected(&"London (GMT)"), office: State::new(OFFICES.iter().copied()), region: State::new(["Europe"]).with_selected(&"Europe"), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Timezone(event) => { let _ = self.timezone.update(event); } Message::Office(event) => { let _ = self.office.update(event); } } } pub fn view(&self) -> Element<'_, Message> { // A required value that is still missing shows as invalid. let office_error = self .office .selected() .is_none() .then_some("Pick the office you work from."); column![ field( "Timezone", combobox(&self.timezone) .placeholder("Search timezones...") .empty("No timezone found.") .width(Length::Fill) .on_event(Message::Timezone), ) .description("Meeting times are shown in this timezone."), field( "Office", combobox(&self.office) .placeholder("Search offices...") .width(Length::Fill) .invalid(office_error.is_some()) .on_event(Message::Office), ) .required(true) .error_maybe(office_error), field("Region", combobox(&self.region).width(Length::Fill)) .description("Set by your organisation.") .disabled(true), ] .spacing(20) .width(Length::Fixed(320.0)) .height(Length::Fill) .into() } } ``` ### Keyboard shortcuts The default keymap with Ctrl+N and Ctrl+P added. The combobox handles its keys itself while it has focus. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::forms::combobox::{self, Action, State, combobox}; use iced_cube::keys::{Chord, Keymap}; const FRAMEWORKS: &[&str] = &[ "Actix", "Axum", "Bevy", "Dioxus", "Iced", "Leptos", "Rocket", "Tauri", "Yew", ]; #[derive(Debug, Clone)] pub enum Message { Framework(combobox::Event), } #[derive(Debug)] pub struct Example { framework: State<&'static str>, keymap: Keymap, } impl Default for Example { fn default() -> Self { let keymap = combobox::default_keymap() .bind(Chord::character('n').ctrl(), Action::Next) .bind(Chord::character('p').ctrl(), Action::Previous); Self { framework: State::new(FRAMEWORKS.iter().copied()).with_visible_rows(5), keymap, } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Framework(event) = message; let _ = self.framework.update(event); } pub fn view(&self) -> Element<'_, Message> { let chosen = self.framework.selected().copied().unwrap_or("nothing yet"); column![ text("Click the field, then use the arrow keys or Ctrl+N and Ctrl+P. Enter chooses, Escape closes.") .size(14), text(format!("Chosen: {chosen}")).size(14), combobox(&self.framework) .placeholder("Search crates...") .keymap(self.keymap.clone()) .on_event(Message::Framework), ] .spacing(12) .width(Length::Fixed(360.0)) .height(Length::Fill) .into() } } ``` ## API - `State::new(options)`: Holds the options, labelled with their Display text. The list starts closed with nothing selected. - `.with_selected(&value) / .with_visible_rows(n)`: Starts with a value selected, and sets how many suggestions show at once (six by default). - `state.update(Event)`: Input(query), Open, Close, Next, Previous, ActivateHighlighted, Highlight(index), Activate(index) or Scroll(rows). Returns the value when one is chosen. - `state.selected() / state.query() / state.is_open()`: The chosen value, the text typed so far and whether the list is showing. - `state.matches() / state.highlighted()`: The options that contain the query, ignoring case, in their original order, and the highlighted one. - `combobox(&state)`: Renders the field and, while open, the floating list. It renders disabled until .on_event is set. - `.on_event(f)`: Maps combobox events to your message, such as Message::Fruit. - `.placeholder(text) / .empty(text)`: Text shown in the empty field, and in the list when nothing matches. The empty text defaults to "No results found." - `.width(length) / .size(Size) / .id(id)`: Overrides the width (240 pixels by default), takes the input sizes Sm, Md (the default) or Lg, and sets the text field's widget id. - `.invalid(bool)`: Draws the field with the destructive border, as input does, for example when a required value is missing. - `.keymap(keymap)`: Replaces the default keymap. The combobox resolves its own key presses while its field has focus or its list is open. - `combobox::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.event(&state)`: Turns an Action into the Event it sends, or None when it would do nothing, for apps that route keys themselves. ## Keyboard - ArrowDown: `Next`. Highlights the next suggestion, opening the list if it is closed. - ArrowUp: `Previous`. Highlights the previous suggestion, opening the list if it is closed. - Enter: `Activate`. Chooses the highlighted suggestion and closes the list. - Escape: `Close`. Closes the list and clears the query. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/select.md - https://sam-ruff.github.io/iced-cube/docs/components/command.md - https://sam-ruff.github.io/iced-cube/docs/components/field.md --- # Card > Groups related content on a raised surface, with an optional header and footer. ```rust use iced_cube::card; ``` ## Usage A card is a container with a soft border, rounded corners and consistent padding. A broad, faint shadow separates it from the page; in dark themes the surface is also a shade lighter so it still reads as raised. The footer takes any element, so you decide how actions are laid out. Buttons inside a card work exactly as they do anywhere else. A link button has no padding at its sides, so a "View all" link in the footer lines up with the body above it. The body fills the card when it contains something that fills, such as a row with a spacer. A `vstack` or `hstack` follows its children the way an iced `column` does, so a stat card whose header row pushes an icon to the right needs no extra width settings. Give the card itself a width, or put it in a layout that sizes it, since a card on its own shrinks to fit. ## Examples ### Header, body and footer A card with a title, description, body text and footer actions. ```rust use iced::widget::{row, text}; use iced::{Element, Length}; use iced_cube::card; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; #[derive(Debug, Clone)] pub enum Message { Later, Upgrade, } #[derive(Debug, Default)] pub struct Example { upgraded: bool, } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Later => self.upgraded = false, Message::Upgrade => self.upgraded = true, } } pub fn view(&self) -> Element<'_, Message> { let body = if self.upgraded { "You are on the Pro plan. Thanks for your support." } else { "You have used 9.2 GB of your 10 GB. Upgrade for 100 GB and priority support." }; card() .title("Storage almost full") .description("Personal workspace") .body(text(body).size(14).width(Length::Fill)) .footer( row![ button("Maybe later") .variant(Variant::Outline) .on_press(Message::Later), button("Upgrade") .icon(lucide!(Sparkles)) .on_press_maybe((!self.upgraded).then_some(Message::Upgrade)), ] .spacing(8), ) .width(380) .into() } } ``` ### Stat cards Cards sharing a row, each with a figure and a badge. ```rust use iced::Element; use iced::widget::{column, row, text}; use iced_cube::feedback::badge::Variant; use iced_cube::{badge, card}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; const STATS: [(&str, &str, &str, Variant); 3] = [ ("Revenue", "£45,231", "+20.1%", Variant::Success), ("Subscribers", "2,350", "+180", Variant::Secondary), ("Churn", "3.2%", "+0.4%", Variant::Destructive), ]; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { row(STATS.map(|(label, value, change, variant)| { card() .description(label) .body( column![ text(value).size(24), badge(format!("{change} this month")).variant(variant), ] .spacing(8), ) .width(200) .into() })) .spacing(16) .wrap() .into() } } ``` ## API - `card()`: Creates an empty card. Every section is optional. - `.title(text) / .description(text)`: Sets the header: a heading and a muted line under it. - `.body(element)`: Sets the main content. - `.footer(element)`: Sets the content under the body, usually a row of buttons. - `.width(length)`: Overrides the width. Cards shrink to fit by default. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/stack.md --- # Stack > Vertical and horizontal stacks that take their spacing from the theme's scale. ```rust use iced_cube::{hstack, vstack}; use iced_cube::layout::stack::Gap; ``` ## Usage Stacks are a thin layer over iced's `column` and `row`. The only difference is that spacing comes from a fixed scale, which keeps gaps consistent across a whole app. Reach for a plain `column` or `row` whenever you need something the stacks do not expose. ## Examples ### Gaps Each step of the spacing scale, from Xs to Xl. ```rust use iced::widget::text; use iced::{Alignment, Element}; use iced_cube::feedback::badge::Variant; use iced_cube::layout::stack::Gap; use iced_cube::{badge, hstack, vstack}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let rows = [Gap::Xs, Gap::Sm, Gap::Md, Gap::Lg, Gap::Xl].map(|gap| { let items = ["One", "Two", "Three", "Four"] .map(|label| badge(label).variant(Variant::Secondary).into()); hstack([ text(format!("{gap:?}")).size(14).width(40).into(), hstack(items).gap(gap).wrap().into(), ]) .align(Alignment::Center) .into() }); vstack(rows).gap(Gap::Md).into() } } ``` ### Alignment Children aligned to the start, centre or end. ```rust use iced::{Alignment, Element, Length}; use iced_cube::layout::stack::Gap; use iced_cube::{badge, card, hstack, vstack}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let panel = |title: &'static str, align: Alignment| -> Element<'_, Message> { card() .title(title) .body( vstack([ badge("Short").into(), badge("A longer label").into(), badge("Medium one").into(), ]) .gap(Gap::Sm) .align(align) .width(Length::Fill), ) .width(180) .into() }; hstack([ panel("Start", Alignment::Start), panel("Centre", Alignment::Center), panel("End", Alignment::End), ]) .gap(Gap::Lg) .wrap() .into() } } ``` ### Wrapping A horizontal stack that flows onto new lines. ```rust use iced::Element; use iced_cube::feedback::badge::Variant; use iced_cube::layout::stack::Gap; use iced_cube::{badge, hstack}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; const TOPICS: [&str; 14] = [ "Rust", "GUI", "Desktop", "Wasm", "Themes", "Icons", "Layout", "Forms", "Tables", "Charts", "Dialogs", "Menus", "Testing", "Accessibility", ]; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { hstack(TOPICS.map(|topic| badge(topic).variant(Variant::Outline).into())) .gap(Gap::Sm) .wrap() .width(360) .into() } } ``` ## API - `vstack(children) / hstack(children)`: Stacks elements top to bottom or left to right. - `.push(element)`: Adds a child at the end. - `.gap(Gap)`: None, Xs, Sm, Md, Lg or Xl. Defaults to Md. - `.align(Alignment)`: Aligns children across the stack: horizontally in a vstack, vertically in an hstack. - `.wrap()`: Moves children onto a new line when they run out of room. - `.padding(padding) / .width(length) / .height(length)`: Sizing, as on any iced column or row. Without a width or height the stack follows its children: it shrinks to fit, or fills when a child fills. - `Gap::pixels()`: The gap in pixels: 0, 4, 8, 12, 16 or 24. - `.len() / .is_empty()`: How many children the stack has. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/card.md - https://sam-ruff.github.io/iced-cube/docs/components/separator.md --- # Separator > A thin line that divides related groups of content, with optional inline text. ```rust use iced_cube::{separator, vertical_separator}; use iced_cube::primitives::separator::Orientation; ``` ## Usage Separators are one pixel wide and take their colour from the theme's border token. Space around them comes from the parent's `spacing`. A vertical separator fills the height of its row, so give the row a height (or let taller siblings set it) when the separator sits between short items such as links. ## Examples ### Horizontal and vertical A horizontal rule between sections and vertical rules between links. ```rust use iced::widget::{column, row, text}; use iced::{Element, Length}; use iced_cube::theme::{Tokens, text_size}; use iced_cube::{separator, vertical_separator}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let muted = |theme: &iced::Theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }; column![ column![ text("iced-cube").size(text_size::MD), text("Components for iced applications.") .size(text_size::SM) .style(muted), ] .spacing(4), separator(), row![ text("Blog").size(text_size::SM), vertical_separator(), text("Docs").size(text_size::SM), vertical_separator(), text("Source").size(text_size::SM), ] .spacing(16) .height(20), ] .spacing(16) .width(Length::Fixed(320.0)) .into() } } ``` ### With label Inline text such as "or" between two alternatives. ```rust use iced::widget::column; use iced::{Element, Length}; use iced_cube::primitives::button::Variant; use iced_cube::{button, input, lucide, separator}; #[derive(Debug, Clone)] pub enum Message { EmailChanged(String), Continue, UseKey, } #[derive(Debug, Default)] pub struct Example { email: String, } impl Example { pub fn update(&mut self, message: Message) { if let Message::EmailChanged(email) = message { self.email = email; } } pub fn view(&self) -> Element<'_, Message> { column![ input("you@example.com", &self.email) .icon(lucide!(Mail)) .on_input(Message::EmailChanged), button("Continue with email") .width(Length::Fill) .on_press(Message::Continue), separator().label("or"), button("Use a security key") .icon(lucide!(KeyRound)) .variant(Variant::Outline) .width(Length::Fill) .on_press(Message::UseKey), ] .spacing(12) .width(Length::Fixed(320.0)) .into() } } ``` ## API - `separator()`: Creates a horizontal line that fills the available width. - `vertical_separator()`: Creates a vertical line that fills the available height. - `.orientation(Orientation)`: Horizontal or Vertical. Defaults to Horizontal. - `.label(text)`: Shows short muted text, such as "or", in the middle of the line. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/stack.md --- # Scroll area > A scrollable region with a thin, rounded scrollbar that follows the theme. ```rust use iced_cube::scroll_area; use iced_cube::primitives::scroll_area::Direction; ``` ## Usage A scroll area only scrolls when its content is larger than the space it is given, so set a height for vertical scrolling and a width for horizontal scrolling. In a single direction the scrollbar sits beside the content and never covers it. When scrolling both ways the two scrollbars float over the right and bottom edges, so leave a little padding there. The mouse wheel, trackpad and dragging the scrollbar all work as usual. ## Examples ### Vertical A long list inside a fixed height. ```rust use iced::widget::{column, rule, text}; use iced::{Element, Length}; use iced_cube::{card, scroll_area}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let tags = column((1..=40).flat_map(|i| { [ text(format!("v1.{i}.0")).size(14).into(), rule::horizontal(1).into(), ] })) .spacing(8) .width(Length::Fill); card() .title("Releases") .body(scroll_area(tags).height(150).width(Length::Fill)) .width(240) .into() } } ``` ### Horizontal A row of cards wider than its container. ```rust use iced::widget::{column, row, text}; use iced::{Element, Length}; use iced_cube::primitives::scroll_area::Direction; use iced_cube::{card, scroll_area}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; const ARTWORKS: [(&str, &str); 8] = [ ("Harbour at dawn", "Nia Evans"), ("Salt flats", "Priya Shah"), ("Glass house", "Owen Hughes"), ("Low tide", "Ines Castro"), ("Night market", "Kenji Sato"), ("First snow", "Mari Lund"), ("Orchard", "Ada Okafor"), ("Quarry", "Luca Moretti"), ]; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let items = row(ARTWORKS.map(|(title, artist)| { card() .body(column![text(title).size(14), text(artist).size(12)].spacing(4)) .width(160) .into() })) .spacing(12) .padding(24); scroll_area(items) .direction(Direction::Horizontal) .width(Length::Fill) .into() } } ``` ### Both directions A grid that overflows both ways. ```rust use iced::widget::{column, row, text}; use iced::{Alignment, Element, Length, Padding}; use iced_cube::primitives::scroll_area::Direction; use iced_cube::{card, scroll_area}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let grid = column((1..=30).map(|y| { row((1..=16).map(|x| { text(x * y) .size(13) .width(40) .align_x(Alignment::End) .into() })) .into() })) .spacing(6) // Room for the scrollbars, which float over the edges. .padding(Padding::ZERO.right(12).bottom(12)); card() .title("Times table") .body( scroll_area(grid) .direction(Direction::Both) .width(Length::Fill) .height(150), ) .width(420) .into() } } ``` ## API - `scroll_area(content)`: Wraps content in a vertically scrolling area. - `.direction(Direction)`: Vertical, Horizontal or Both. Defaults to Vertical. - `.width(length) / .height(length)`: Sets the size of the visible region. Give the scrolling axis a fixed or fill length. - `.id(id)`: Sets the widget id, so the area can be scrolled with iced's scrolling tasks. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/stack.md - https://sam-ruff.github.io/iced-cube/docs/components/card.md --- # Accordion > Stacked sections that expand and collapse under their headers. ```rust use iced_cube::layout::accordion::{self, Mode, accordion}; ``` ## Usage Use an accordion to shorten long pages such as FAQs or settings, where people only need one or two sections at a time. The chevron points down while a section is closed and up while it is open. Semibold headings separate each section from its muted body text. Content inherits the muted colour by default; text and controls with their own styles keep those colours. Clicking the header of an open section closes it in both modes. The state does not know which sections exist, so keyboard actions take the section ids in display order. `Next` and `Previous` move the most recently opened section along that list, and `ExpandAll` only applies in Multiple mode. ## Examples ### Single Opening a section closes the others. ```rust use iced::widget::{container, text}; use iced::{Element, Length}; use iced_cube::layout::accordion::{self, Mode, State, accordion}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Question { Shipping, Returns, Support, } #[derive(Debug, Clone)] pub enum Message { Accordion(accordion::Event), } #[derive(Debug)] pub struct Example { sections: State, } impl Default for Example { fn default() -> Self { Self { sections: State::new(Mode::Single).with_open(Question::Shipping), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Accordion(event) = message; let _ = self.sections.update(event); } pub fn view(&self) -> Element<'_, Message> { let answer = |body| text(body).size(14); container( accordion(&self.sections) .item( Question::Shipping, "How long does shipping take?", answer("Orders leave the warehouse within two working days."), ) .item( Question::Returns, "Can I return an item?", answer("Yes, within 30 days, as long as it is unused."), ) .item( Question::Support, "How do I contact support?", answer("Email us any time and we reply within a day."), ) .on_event(Message::Accordion), ) .width(Length::Fixed(440.0)) .into() } } ``` ### Multiple Sections open and close independently. ```rust use iced::widget::{container, text}; use iced::{Element, Length}; use iced_cube::layout::accordion::{self, Mode, State, accordion}; #[derive(Debug, Clone)] pub enum Message { Accordion(accordion::Event<&'static str>), } #[derive(Debug)] pub struct Example { sections: State<&'static str>, } impl Default for Example { fn default() -> Self { Self { sections: State::new(Mode::Multiple) .with_open("general") .with_open("privacy"), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Accordion(event) = message; let _ = self.sections.update(event); } pub fn view(&self) -> Element<'_, Message> { container( accordion(&self.sections) .item( "general", "General", text("Language, region and start page.").size(14), ) .item( "privacy", "Privacy", text("Who can see your profile and activity.").size(14), ) .item( "alerts", "Notifications", text("Email and desktop alerts.").size(14), ) .on_event(Message::Accordion), ) .width(Length::Fixed(440.0)) .into() } } ``` ## API - `State::new(Mode)`: Starts with every section closed. Single keeps at most one open; Multiple opens each independently. - `.with_open(id)`: Starts with a section open. - `state.update(Event)`: Toggle(id), Open(id) or Close(id). Returns Opened(id) or Closed(id) when something changed. - `state.is_open(id) / state.open()`: Checks one section, or lists the open ones in the order they opened. - `state.set_mode(Mode)`: Switches mode. Going to Single keeps only the first open section. - `accordion(&state)`: Renders the sections. Headers render disabled until .on_event is set. - `.item(id, title, content)`: Adds a section. Closed sections are not laid out or drawn, but their content is still built on every view, so check state.is_open(id) first if it is expensive. - `state.mode()`: The current Mode. - `.on_event(f)`: Maps accordion events to your message. - `accordion::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.events(&state, §ions)`: The events for a resolved Action, given every section id in display order. Pass each one to state.update. ## Keyboard - ArrowDown: `Next`. Opens the next section in place of the current one, wrapping at the end. - ArrowUp: `Previous`. Opens the previous section in place of the current one, wrapping at the start. - Ctrl+Shift+ArrowDown: `ExpandAll`. Opens every section. Only applies when several may be open. - Ctrl+Shift+ArrowUp: `CollapseAll`. Closes every open section. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/tabs.md - https://sam-ruff.github.io/iced-cube/docs/components/card.md --- # Resizable panel > Two or more panels in a row or column, separated by handles that resize them. ```rust use iced_cube::layout::resizable_panel::{self, Event, Extent, State, panel, resizable_panel}; ``` ## Usage The app owns the sizes. They live in `State` as fractions of the space the panels share, so saving `state.sizes()` and passing it back to `with_sizes` restores the layout at any window size. Pixel limits are applied against the current size every time the group is laid out. Dragging a handle moves the panels on either side of it. When the panel that is shrinking reaches its minimum, the panels beyond it shrink too. A collapsible panel snaps shut once it is dragged below its threshold and snaps open again, at its minimum, once it is dragged back past it. `Event::Toggle` collapses a panel or brings it back to the size it had before. The group sends `Event::Measure` when its size changes, followed by the drag events. Pass them all to `state.update`. ## Keyboard focus iced 0.14 has no general focus model, but widgets can take part in its focus operations. Each handle is focusable: pressing it focuses it, just as a text field is focused, and `iced::widget::operation::focus(id)` or `focus_next()` reach it too. While a handle has focus the group resolves its own keymap and captures the keys it uses, so no subscription is needed. Escape or a press anywhere else takes the focus away. ## Small screens When the minimums do not fit, collapsible panels collapse, starting with the last one, and if that is not enough every panel shrinks in proportion. For a row that would be too cramped on a phone, `.stack_below(480.0)` turns it into a column below that width, keeping the same shares. ## Examples ### Horizontal Three panels in a row with grips on the handles. Each shows its share of the width. ```rust use iced::widget::{column, container, text}; use iced::{Alignment, Element, Length}; use iced_cube::layout::card; use iced_cube::layout::resizable_panel::{Event, Extent, State, panel, resizable_panel}; use iced_cube::theme::{Tokens, semibold, text_size}; #[derive(Debug, Clone)] pub enum Message { Panels(Event), } #[derive(Debug)] pub struct Example { panels: State, } impl Default for Example { fn default() -> Self { Self { panels: State::new([ panel(Extent::Fraction(0.25)).min(Extent::Pixels(64.0)), panel(Extent::Fraction(0.5)).min(Extent::Pixels(64.0)), panel(Extent::Fraction(0.25)).min(Extent::Pixels(64.0)), ]), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Panels(event) = message; let _ = self.panels.update(event); } pub fn view(&self) -> Element<'_, Message> { let sizes = self.panels.sizes(); let panes = ["One", "Two", "Three"] .into_iter() .enumerate() .map(|(index, name)| { let share = sizes.get(index).copied().unwrap_or_default(); pane(name, format!("{:.0}%", share * 100.0)) }); container( resizable_panel(&self.panels, panes) .grip(true) .on_event(Message::Panels), ) .padding(1) .height(200) .style(|theme| card::style(&Tokens::of(theme))) .into() } } fn pane<'a>(name: &'a str, share: String) -> Element<'a, Message> { container( column![ text(name).size(text_size::SM).font(semibold()), text(share).size(text_size::XS).style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .spacing(2) .align_x(Alignment::Center), ) .center(Length::Fill) .into() } ``` ### Vertical A header and content stacked in a column. ```rust use iced::widget::{column, container, text}; use iced::{Element, Length}; use iced_cube::layout::card; use iced_cube::layout::resizable_panel::{Axis, Event, Extent, State, panel, resizable_panel}; use iced_cube::theme::{Tokens, semibold, text_size}; #[derive(Debug, Clone)] pub enum Message { Panels(Event), } #[derive(Debug)] pub struct Example { panels: State, } impl Default for Example { fn default() -> Self { Self { panels: State::new([ panel(Extent::Fraction(0.3)).min(Extent::Pixels(48.0)), panel(Extent::Fraction(0.7)).min(Extent::Pixels(64.0)), ]), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Panels(event) = message; let _ = self.panels.update(event); } pub fn view(&self) -> Element<'_, Message> { let header = container(text("Header").size(text_size::SM).font(semibold())).center(Length::Fill); let body = container( column![ text("Content").size(text_size::SM).font(semibold()), text("Drag the handle up or down.") .size(text_size::XS) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .spacing(2) .align_x(iced::Alignment::Center), ) .center(Length::Fill); container( resizable_panel(&self.panels, [header.into(), body.into()]) .axis(Axis::Vertical) .on_event(Message::Panels), ) .padding(1) .width(Length::Fill) .max_width(420) .height(240) .style(|theme| card::style(&Tokens::of(theme))) .into() } } ``` ### Nested A column of two panels inside a row. Below 480 pixels the row stacks into a column. ```rust use iced::widget::{container, text}; use iced::{Element, Length}; use iced_cube::layout::card; use iced_cube::layout::resizable_panel::{Axis, Event, Extent, State, panel, resizable_panel}; use iced_cube::theme::{Tokens, semibold, text_size}; #[derive(Debug, Clone)] pub enum Message { Outer(Event), Inner(Event), } #[derive(Debug)] pub struct Example { outer: State, inner: State, } impl Default for Example { fn default() -> Self { Self { outer: State::new([ panel(Extent::Fraction(0.35)).min(Extent::Pixels(96.0)), panel(Extent::Fraction(0.65)).min(Extent::Pixels(160.0)), ]), inner: State::new([ panel(Extent::Fraction(0.6)).min(Extent::Pixels(40.0)), panel(Extent::Fraction(0.4)).min(Extent::Pixels(40.0)), ]), } } } impl Example { pub fn update(&mut self, message: Message) { let _ = match message { Message::Outer(event) => self.outer.update(event), Message::Inner(event) => self.inner.update(event), }; } pub fn view(&self) -> Element<'_, Message> { let editor = resizable_panel(&self.inner, [pane("Editor"), pane("Terminal")]) .axis(Axis::Vertical) .on_event(Message::Inner); container( resizable_panel(&self.outer, [pane("Files"), editor.into()]) .stack_below(480.0) .on_event(Message::Outer), ) .padding(1) .height(240) .style(|theme| card::style(&Tokens::of(theme))) .into() } } fn pane(name: &str) -> Element<'_, Message> { container(text(name).size(text_size::SM).font(semibold())) .center(Length::Fill) .into() } ``` ### Collapsible with limits A folder list between 140 pixels and half the width that snaps shut below its threshold, with a button that toggles it. ```rust use iced::widget::{column, container, row, text}; use iced::{Alignment, Element, Length}; use iced_cube::layout::card; use iced_cube::layout::resizable_panel::{Event, Extent, State, panel, resizable_panel}; use iced_cube::primitives::button::{Size, Variant}; use iced_cube::theme::{Tokens, semibold, text_size}; use iced_cube::{button, icon, lucide}; const FOLDERS: [&str; 4] = ["Inbox", "Drafts", "Sent", "Archive"]; #[derive(Debug, Clone)] pub enum Message { Panels(Event), Toggle, } #[derive(Debug)] pub struct Example { panels: State, } impl Default for Example { fn default() -> Self { Self { panels: State::new([ panel(Extent::Fraction(0.35)) .min(Extent::Pixels(140.0)) .max(Extent::Fraction(0.5)) .collapsible(true), panel(Extent::Fraction(0.65)).min(Extent::Pixels(120.0)), ]), } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Panels(event) => event, Message::Toggle => Event::Toggle(0), }; let _ = self.panels.update(event); } pub fn view(&self) -> Element<'_, Message> { let folders = column(FOLDERS.map(|name| { row![ icon::icon(lucide!(Folder), 16.0), text(name).size(text_size::SM), ] .spacing(8) .align_y(Alignment::Center) .into() })) .spacing(12) .padding(16); let collapsed = self.panels.is_collapsed(0); let label = if collapsed { "Show" } else { "Hide" }; let content = container( column![ text("Messages").size(text_size::SM).font(semibold()), text("Drag the handle below 70px and the folders snap shut. They open again past it, and stay between 140px and half the width.") .size(text_size::XS) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), button(label) .icon(lucide!(PanelLeft)) .variant(Variant::Outline) .size(Size::Sm) .on_press(Message::Toggle), ] .spacing(8), ) .padding(16) .width(Length::Fill) .height(Length::Fill); container( resizable_panel(&self.panels, [folders.into(), content.into()]) .grip(true) .on_event(Message::Panels), ) .padding(1) .height(220) .style(|theme| card::style(&Tokens::of(theme))) .into() } } ``` ### Keyboard shortcuts A focused handle resolves the default keymap, with Page Up and Page Down added. A press focuses the handle, and so does widget::operation::focus with its id. ```rust use iced::keyboard::key::Named; use iced::widget::{self, column, container, text}; use iced::{Element, Length, Task}; use iced_cube::button; use iced_cube::keys::{Chord, Keymap}; use iced_cube::layout::card; use iced_cube::layout::resizable_panel::{ self, Action, Event, Extent, State, panel, resizable_panel, }; use iced_cube::primitives::button::{Size, Variant}; use iced_cube::theme::{Tokens, semibold, text_size}; const HANDLE: widget::Id = widget::Id::new("keyboard-handle"); #[derive(Debug, Clone)] pub enum Message { Panels(Event), FocusHandle, } #[derive(Debug)] pub struct Example { panels: State, keymap: Keymap, } impl Default for Example { fn default() -> Self { Self { panels: State::new([ panel(Extent::Fraction(0.4)) .min(Extent::Pixels(80.0)) .collapsible(true), panel(Extent::Fraction(0.6)).min(Extent::Pixels(120.0)), ]), keymap: resizable_panel::default_keymap() .bind(Chord::named(Named::PageUp), Action::DecreaseMore) .bind(Chord::named(Named::PageDown), Action::IncreaseMore), } } } impl Example { pub fn update(&mut self, message: Message) -> Task { match message { Message::Panels(event) => { let _ = self.panels.update(event); Task::none() } Message::FocusHandle => widget::operation::focus(HANDLE), } } pub fn view(&self) -> Element<'_, Message> { let share = self.panels.sizes().first().copied().unwrap_or_default(); let left = container( text(format!("{:.0}%", share * 100.0)) .size(text_size::SM) .font(semibold()), ) .center(Length::Fill); let right = container( button("Focus the handle") .variant(Variant::Outline) .size(Size::Sm) .on_press(Message::FocusHandle), ) .center(Length::Fill); column![ container( resizable_panel(&self.panels, [left.into(), right.into()]) .id(HANDLE) .grip(true) .keymap(self.keymap.clone()) .on_event(Message::Panels), ) .padding(1) .height(140) .style(|theme| card::style(&Tokens::of(theme))), text("Click the handle or the button, then use the arrows to step by 5%. Shift, Page Up or Page Down step by 20%, Home and End go to the limits, Enter collapses the left panel and Escape lets go.") .size(text_size::SM), ] .spacing(16) .width(Length::Fill) .max_width(480) .into() } } ``` ## API - `panel(Extent)`: One panel's constraints, starting at a default size given as Extent::Fraction or Extent::Pixels. - `.min(Extent) / .max(Extent)`: The smallest and largest the panel may get. The maximum is never below the minimum. - `.collapsible(bool) / .collapsed_size(Extent) / .collapse_below(Extent)`: Lets the panel snap shut. It collapses to its collapsed size, 0 by default, when dragged below the threshold, which defaults to halfway between that size and the minimum. - `.collapsed(bool)`: Starts a collapsible panel collapsed. - `State::new(panels)`: Holds the panels and their sizes. Sizes are worked out when the group first measures itself. - `.with_sizes(sizes) / state.set_sizes(sizes)`: Restores saved sizes. They are scaled to add up to 1 and fitted to the limits. - `.step(Extent) / .large_step(Extent)`: How far the arrow keys and Shift with an arrow move a handle. Default 5% and 20%. - `state.update(Event)`: Applies a drag, a keyboard step, Collapse, Expand, Toggle or Reset. Returns Output::Resized, Collapsed(i) or Expanded(i) when something changed. - `state.sizes()`: Each panel's share of the space, adding up to 1, ready to save. - `state.resolve(total) / state.is_collapsed(i) / state.dragging()`: The sizes in pixels for a length, whether a panel is collapsed, and the handle being dragged. - `resizable_panel(&state, panes)`: Lays out one element per panel with handles between them. The handles do nothing until .on_event is set. - `.axis(Axis)`: Horizontal for a row, the default, or Vertical for a column. - `.stack_below(width)`: Turns a horizontal group into a column while it is narrower than the given width. - `.grip(bool)`: Draws a grip on each handle. - `.id(id)`: Gives the first handle an id, so widget::operation::focus can focus it. - `.keymap(Keymap) / resizable_panel::default_keymap()`: The shortcuts a focused handle resolves. Bind, unbind or clear chords to change them. - `action.event(&state, handle)`: The Event an Action sends for a handle, for apps that route keys themselves. - `resizable_panel::resize / fit / defaults`: The pure size maths the state uses, for tests or custom layouts. ## Keyboard - ArrowLeft or ArrowUp: `Decrease`. Moves the focused handle one step towards the start. - ArrowRight or ArrowDown: `Increase`. Moves the focused handle one step towards the end. - Shift+ArrowLeft or Shift+ArrowUp: `DecreaseMore`. Moves the focused handle a large step towards the start. - Shift+ArrowRight or Shift+ArrowDown: `IncreaseMore`. Moves the focused handle a large step towards the end. - Home: `Min`. Shrinks the panel before the handle to its minimum. - End: `Max`. Grows the panel before the handle to its maximum. - Enter: `Toggle`. Collapses or expands the collapsible panel next to the handle. - Escape: `Release`. Takes the focus away from the handle. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/split-pane.md - https://sam-ruff.github.io/iced-cube/docs/components/sidebar.md - https://sam-ruff.github.io/iced-cube/docs/components/card.md --- # Split pane > Two views, a start and an end, with one handle between them and a ratio that says how the space is shared. ```rust use iced_cube::layout::split_pane::{Axis, Event, Extent, State, split_pane}; ``` ## Usage A split pane is a resizable panel group with exactly two panels, so it is a thin wrapper rather than a widget of its own. The state wraps the group's state and adds the ratio, and the view is the same widget. Dragging, keyboard resizing, collapsing and stacking on small screens all behave exactly as they do for resizable panels, and the events, keymap and actions are the same types. Use a split pane when there are two views and the ratio is all you need to keep. For three or more panels, or for a sidebar with a pixel width beside a flexible page, use a resizable panel. ## Examples ### Editor and preview A text editor beside its live preview. Below 480 pixels the two stack. ```rust use iced::widget::text_editor::{Action, Content}; use iced::widget::{column, container, text}; use iced::{Element, Length}; use iced_cube::layout::card; use iced_cube::layout::split_pane::{Event, Extent, State, split_pane}; use iced_cube::theme::{Tokens, semibold, text_size}; use iced_cube::{scroll_area, textarea}; const DRAFT: &str = "# Release notes\n\nPanels can now be resized.\n\n# Fixes\n\nMenus close on Escape."; #[derive(Debug, Clone)] pub enum Message { Edit(Action), Split(Event), } #[derive(Debug)] pub struct Example { content: Content, split: State, } impl Default for Example { fn default() -> Self { Self { content: Content::with_text(DRAFT), split: State::new(0.5).min(Extent::Pixels(120.0), Extent::Pixels(120.0)), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Edit(action) => self.content.perform(action), Message::Split(event) => { let _ = self.split.update(event); } } } pub fn view(&self) -> Element<'_, Message> { let editor = container( textarea(&self.content) .height(Length::Fill) .on_action(Message::Edit), ) .padding(12); let preview = self .content .lines() .filter(|line| !line.text.trim().is_empty()) .map(|line| match line.text.strip_prefix("# ") { Some(heading) => text(heading.to_owned()) .size(text_size::LG) .font(semibold()) .into(), None => text(line.text.to_string()).size(text_size::SM).into(), }); let preview = scroll_area(column(preview).spacing(8).padding(16).width(Length::Fill)) .width(Length::Fill) .height(Length::Fill); container( split_pane(&self.split, editor, preview) .stack_below(480.0) .on_event(Message::Split), ) .padding(1) .height(300) .style(|theme| card::style(&Tokens::of(theme))) .into() } } ``` ### Vertical A query above its results, each with a minimum height. ```rust use iced::widget::{column, container, row, text}; use iced::{Element, Length}; use iced_cube::layout::card; use iced_cube::layout::split_pane::{Axis, Event, Extent, State, split_pane}; use iced_cube::scroll_area; use iced_cube::theme::{Tokens, semibold, text_size}; const RESULTS: [(&str, &str); 5] = [ ("Nightly backup", "Succeeded"), ("Search index", "Running"), ("Invoice export", "Queued"), ("Image resize", "Failed"), ("Cache warm-up", "Succeeded"), ]; #[derive(Debug, Clone)] pub enum Message { Split(Event), } #[derive(Debug)] pub struct Example { split: State, } impl Default for Example { fn default() -> Self { Self { split: State::new(0.4).min(Extent::Pixels(56.0), Extent::Pixels(72.0)), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Split(event) = message; let _ = self.split.update(event); } pub fn view(&self) -> Element<'_, Message> { let query = container( column![ text("Query").size(text_size::SM).font(semibold()), text("status != archived, sorted by name") .size(text_size::SM) .style(muted), ] .spacing(4), ) .padding(16) .width(Length::Fill); let rows = RESULTS.map(|(name, status)| { row![ text(name).size(text_size::SM).width(Length::Fill), text(status).size(text_size::SM).style(muted), ] .into() }); let results = scroll_area(column(rows).spacing(10).padding(16)) .width(Length::Fill) .height(Length::Fill); container( split_pane(&self.split, query, results) .axis(Axis::Vertical) .on_event(Message::Split), ) .padding(1) .width(Length::Fill) .max_width(480) .height(300) .style(|theme| card::style(&Tokens::of(theme))) .into() } } fn muted(theme: &iced::Theme) -> text::Style { text::Style { color: Some(Tokens::of(theme).muted_foreground), } } ``` ## API - `State::new(ratio)`: A split with the start view taking the given share, from 0.0 to 1.0. Out of range values are clamped. State::default() is an even split. - `.min(start, end) / .max(start, end)`: The smallest and largest each view may get, as Extent::Fraction or Extent::Pixels. - `.collapsible(start, end)`: Lets either view snap shut when dragged below its threshold. - `State::with_panels(start, end)`: Builds the split from two resizable panel settings, for anything the shortcuts above do not cover. - `.with_ratio(ratio) / state.set_ratio(ratio)`: Restores a saved ratio, fitted to the limits. - `state.ratio()`: The start view's share, ready to save. - `state.update(Event)`: Applies the events the view sends. Returns Output::Resized, Collapsed or Expanded. - `state.is_start_collapsed() / state.is_end_collapsed()`: Whether either view is collapsed. - `split_pane(&state, start, end)`: Shows the two views with a handle between them. The handle does nothing until .on_event is set. - `.axis(Axis) / .stack_below(width)`: Side by side by default, or stacked with Axis::Vertical. stack_below stacks a horizontal split on narrow screens. - `.grip(bool)`: Shows a grip on the handle. On by default. - `.id(id) / .keymap(Keymap)`: The handle's id for focusing it, and the shortcuts it resolves while focused. ## Keyboard - ArrowLeft or ArrowUp: `Decrease`. Moves the focused handle one step towards the start. - ArrowRight or ArrowDown: `Increase`. Moves the focused handle one step towards the end. - Shift+ArrowLeft or Shift+ArrowUp: `DecreaseMore`. Moves the focused handle a large step towards the start. - Shift+ArrowRight or Shift+ArrowDown: `IncreaseMore`. Moves the focused handle a large step towards the end. - Home: `Min`. Shrinks the panel before the handle to its minimum. - End: `Max`. Grows the panel before the handle to its maximum. - Enter: `Toggle`. Collapses or expands the collapsible panel next to the handle. - Escape: `Release`. Takes the focus away from the handle. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/resizable-panel.md --- # Tabs > Switches between related views that share one place on screen. ```rust use iced_cube::navigation::tabs::{self, Variant, tab, tabs}; ``` ## Usage The app owns a `tabs::State` and routes events into it, then renders the view for `state.selected()` itself. The tab list is only the switcher, so the content can be anything. `Next` and `Previous` wrap round at the ends and skip disabled tabs. The widget does not listen to the keyboard itself: the app subscribes with `keys::subscription()` and resolves each key press through a keymap, as the keyboard example does. The arrow keys, Home and End are bound app-wide, so an app with text inputs or other arrow-driven controls may want to unbind them and keep `Ctrl+Tab`. [Document tabs](https://sam-ruff.github.io/iced-cube/docs/components/document-tabs.md) also bind `Ctrl+Tab` and `Ctrl+Shift+Tab`. When both are on screen, unbind the chords from one of the keymaps, usually this one, with `tabs::default_keymap().unbind(&Chord::named(Named::Tab).ctrl()).unbind(&Chord::named(Named::Tab).ctrl().shift())`. See [Keyboard shortcuts](https://sam-ruff.github.io/iced-cube/docs/keyboard.md) for other shared keys. Use the underline variant for page sections and the pills variant for a compact switch between views of the same data. ## Examples ### Underline Labels over a rule, with a disabled tab. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::navigation::tabs::{self, State, tab, tabs}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Section { Overview, Activity, Settings, Billing, } #[derive(Debug, Clone)] pub enum Message { Tabs(tabs::Event
), } #[derive(Debug)] pub struct Example { tabs: State
, } impl Default for Example { fn default() -> Self { Self { tabs: State::new([ tab(Section::Overview, "Overview"), tab(Section::Activity, "Activity"), tab(Section::Settings, "Settings"), tab(Section::Billing, "Billing").disabled(true), ]), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Tabs(event) = message; let _ = self.tabs.update(event); } pub fn view(&self) -> Element<'_, Message> { let body = match self.tabs.selected() { Some(Section::Overview) => "Three projects, two of them active this week.", Some(Section::Activity) => "Nothing new since your last visit.", Some(Section::Settings) => "Project name, visibility and members.", Some(Section::Billing) | None => "", }; column![ tabs(&self.tabs).on_event(Message::Tabs), text(body).size(14), ] .spacing(16) .width(Length::Fixed(420.0)) .into() } } ``` ### Pills A segmented control with icons. ```rust use iced::widget::{column, text}; use iced::{Alignment, Element}; use iced_cube::lucide; use iced_cube::navigation::tabs::{self, State, Variant, tab, tabs}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum View { List, Board, Calendar, } #[derive(Debug, Clone)] pub enum Message { Tabs(tabs::Event), } #[derive(Debug)] pub struct Example { tabs: State, } impl Default for Example { fn default() -> Self { let tabs = State::new([ tab(View::List, "List").icon(lucide!(List)), tab(View::Board, "Board").icon(lucide!(Columns3)), tab(View::Calendar, "Calendar").icon(lucide!(Calendar)), ]); Self { tabs: tabs.with_selected(View::Board), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Tabs(event) = message; let _ = self.tabs.update(event); } pub fn view(&self) -> Element<'_, Message> { let showing = self .tabs .selected() .map_or(String::new(), |view| format!("Showing the {view:?} view")); column![ tabs(&self.tabs) .variant(Variant::Pills) .on_event(Message::Tabs), text(showing).size(14), ] .spacing(16) .align_x(Alignment::Center) .into() } } ``` ### Keyboard shortcuts Key presses resolve through the default keymap, skipping disabled tabs. In the browser, use the arrow keys, Home and End: browsers keep Ctrl+Tab for themselves. ```rust use iced::widget::{column, text}; use iced::{Element, Length, Subscription}; use iced_cube::keys::{self, Keymap}; use iced_cube::navigation::tabs::{self, Action, Event, State, tab, tabs}; #[derive(Debug, Clone)] pub enum Message { Tabs(Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { tabs: State, keymap: Keymap, } impl Default for Example { fn default() -> Self { Self { tabs: State::new([ tab(1, "Step one"), tab(2, "Step two").disabled(true), tab(3, "Step three"), tab(4, "Step four"), ]), keymap: tabs::default_keymap(), } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Tabs(event) => Some(event), Message::Key(key) => self .keymap .resolve_event(&key) .and_then(|action| action.event(&self.tabs)), }; if let Some(event) = event { let _ = self.tabs.update(event); } } pub fn view(&self) -> Element<'_, Message> { column![ tabs(&self.tabs).on_event(Message::Tabs), text("Arrow keys or Ctrl+Tab move between tabs. Home and End jump to the ends.") .size(14), ] .spacing(16) .width(Length::Fixed(460.0)) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `State::new(tabs)`: Holds the tabs and selects the first enabled one. - `.with_selected(id)`: Starts on a given tab, if it exists and is enabled. - `tab(id, label)`: Creates a tab. Add .icon(glyph) or .disabled(true). - `state.update(Event)`: Select(id), Next or Previous. Returns the new id when the selection changed. - `state.selected() / state.is_selected(id)`: The selected id, or None when every tab is disabled, and a check for one id. - `state.tabs()`: The tabs, in order. - `state.set_disabled(id, bool)`: Enables or disables a tab at runtime. - `tabs(&state)`: Renders the tab list. Tabs render disabled until .on_event is set. - `.variant(Variant)`: Underline or Pills. Defaults to Underline. - `.width(length)`: Overrides the width. Underline tabs fill the available width and pills shrink to fit by default. - `.on_event(f)`: Maps tab events to your message, such as Message::Tabs. - `tabs::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.event(&state)`: Turns a resolved Action (Next, Previous, First or Last) into the Event to pass to state.update. ## Keyboard - Ctrl+Tab or ArrowRight: `Next`. Selects the next enabled tab, wrapping at the end. - Ctrl+Shift+Tab or ArrowLeft: `Previous`. Selects the previous enabled tab, wrapping at the start. - Home: `First`. Selects the first enabled tab. - End: `Last`. Selects the last enabled tab. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/accordion.md --- # Command > A searchable list of actions, grouped, with icons and shortcut hints. ```rust use iced_cube::navigation::command::{self, Output, State, command, group, item}; ``` ## Usage A command list puts every action behind one search field. Typing ranks the items within each group: an exact match comes first, then prefixes, then matches at the start of a word, then anywhere, then letters in order. Groups keep their order, and the highlight goes to the best match in any group, so Enter runs what was typed for. Keywords let an item be found by other names, such as "preferences" for Settings. Disabled items stay visible but are skipped by the keyboard. The list is embedded where you place it. For an app-wide launcher, use the [command palette](https://sam-ruff.github.io/iced-cube/docs/components/command-palette.md), which builds this list into a dialog with the Ctrl+K and Ctrl+Shift+P chords, recent commands and nested pages. To compose one yourself, put the list in a [dialog](https://sam-ruff.github.io/iced-cube/docs/components/dialog.md) and open it from a button or a shortcut such as Ctrl+K, as the palette example does. Let that shortcut through the dialog with `.pass_through(...)` so it closes the palette too, and close the dialog when `update` returns `Output::Activated` or `Output::Closed`. Give a palette `.max_height(...)` rather than a fixed height, so it shrinks around a handful of matches instead of leaving an empty panel. Results can also come from background work, such as a file index or a server. When `update` returns `Output::Search(query)`, start a producer with that query and a clone of the sender. It sends `command::results(query, group, items)` as matches arrive; anything tagged with an older query is dropped, so slow searches never overwrite newer ones. The async example waits with `futures-timer` rather than a thread, so it runs in the browser too. While the search field has focus, the list handles its keys itself, before a surrounding dialog or popover sees them. Home and End then move the highlight rather than the text cursor. Escape clears the query, and a second press returns `Output::Closed`. The highlighted row is always the target of Enter, but it is only drawn highlighted while the field has focus or after the user moves the highlight with the keys or the pointer. An inline list in a sidebar therefore sits quietly until someone uses it. The widget reports focus changes with `Event::Focus`, so route every event to `update`. ## Examples ### Inline Grouped actions with icons, shortcut hints, keywords and a disabled row. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::lucide; use iced_cube::navigation::command::{self, Output, State, command, group, item}; #[derive(Debug, Clone)] pub enum Message { Command(command::Event<&'static str>), } #[derive(Debug)] pub struct Example { command: State<&'static str>, last_run: Option<&'static str>, } impl Default for Example { fn default() -> Self { let command = State::new([ group( "Suggestions", [ item("calendar", "Calendar").icon(lucide!(Calendar)), item("new-file", "New file") .icon(lucide!(FilePlus)) .keywords(["create", "document"]), item("calculator", "Calculator") .icon(lucide!(Calculator)) .disabled(true), ], ), group( "Settings", [ item("profile", "Profile") .icon(lucide!(User)) .shortcut("Ctrl+P"), item("billing", "Billing") .icon(lucide!(CreditCard)) .shortcut("Ctrl+B"), item("settings", "Settings") .icon(lucide!(Settings)) .shortcut("Ctrl+S") .keywords(["preferences", "options"]), ], ), ]); Self { command, last_run: None, } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Command(event) = message; if let Some(Output::Activated(id)) = self.command.update(event) { self.last_run = Some(id); } } pub fn view(&self) -> Element<'_, Message> { let status = match self.last_run { Some(id) => format!("Ran \"{id}\""), None => "Click a row, or type and press Enter.".to_owned(), }; column![ command(&self.command) .height(320) .on_event(Message::Command), text(status).size(14), ] .spacing(12) .width(Length::Fixed(420.0)) .into() } } ``` ### Keyboard shortcuts The default keymap with Ctrl+N and Ctrl+P added. The list handles keys while its field has focus; the app routes the rest through keys::subscription. ```rust use iced::widget::{column, text}; use iced::{Element, Length, Subscription}; use iced_cube::keys::{self, Chord, Keymap}; use iced_cube::lucide; use iced_cube::navigation::command::{self, Action, Output, State, command, group, item}; #[derive(Debug, Clone)] pub enum Message { Command(command::Event<&'static str>), Key(keys::Event), } #[derive(Debug)] pub struct Example { command: State<&'static str>, keymap: Keymap, last_run: Option<&'static str>, } impl Default for Example { fn default() -> Self { let command = State::new([ group( "Navigation", [ item("home", "Go to home").icon(lucide!(House)), item("inbox", "Go to inbox").icon(lucide!(Mail)), item("terminal", "Open terminal") .icon(lucide!(Terminal)) .shortcut("Ctrl+`"), ], ), group( "Danger zone", [ item("sign-out", "Sign out").icon(lucide!(LogOut)), item("delete", "Delete project") .icon(lucide!(Trash)) .destructive(true), ], ), ]); let keymap = command::default_keymap() .bind(Chord::character('n').ctrl(), Action::Next) .bind(Chord::character('p').ctrl(), Action::Previous); Self { command, keymap, last_run: None, } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Command(event) => Some(event), // Presses the focused search field did not handle. Message::Key(key) => self .keymap .resolve_event(&key) .and_then(|action| action.event(&self.command)), }; let Some(event) = event else { return; }; if let Some(Output::Activated(id)) = self.command.update(event) { self.last_run = Some(id); } } pub fn view(&self) -> Element<'_, Message> { let status = match self.last_run { Some(id) => format!("Ran \"{id}\""), None => "Arrows or Ctrl+N and Ctrl+P move, Home and End jump, Enter runs.".to_owned(), }; column![ command(&self.command) .placeholder("Search actions...") .keymap(self.keymap.clone()) .height(300) .on_event(Message::Command), text(status).size(14), ] .spacing(12) .width(Length::Fixed(420.0)) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ### Async results A background task streams file matches through the channel, tagged with their query. It waits with futures-timer, so it also runs in the browser. ```rust use std::hash::{Hash, Hasher}; use std::time::Duration; use futures_timer::Delay; use iced::futures::{SinkExt, Stream, stream}; use iced::widget::{column, text}; use iced::{Element, Length, Subscription}; use iced_cube::lucide; use iced_cube::navigation::command::{self, Output, State, command, group, item, results, score}; const FILES: &[&str] = &[ "budget-2026.xlsx", "design-review.pdf", "invoice-0142.pdf", "meeting-notes.md", "readme.md", "release-plan.md", "report-q3.pdf", "roadmap.png", "team-photo.jpg", "wireframes.fig", ]; #[derive(Debug, Clone)] pub enum Message { Command(command::Event<&'static str>), SearchFinished(String), } #[derive(Debug)] pub struct Example { command: State<&'static str>, sender: Option>, search: Option, last_run: Option<&'static str>, } impl Default for Example { fn default() -> Self { let command = State::new([group( "Actions", [ item("new-file", "New file").icon(lucide!(FilePlus)), item("upload", "Upload files").icon(lucide!(FileUp)), item("open-folder", "Open folder").icon(lucide!(FolderOpen)), ], )]) .with_visible_rows(6); Self { command, sender: None, search: None, last_run: None, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Command(event) => match self.command.update(event) { Some(Output::Ready(sender)) => self.sender = Some(sender), Some(Output::Search(query)) => { self.search = self .sender .clone() .filter(|_| !query.trim().is_empty()) .map(|sender| Search { query, sender }); } Some(Output::Activated(id)) => self.last_run = Some(id), Some(Output::Closed) | None => {} }, Message::SearchFinished(query) => { if self .search .as_ref() .is_some_and(|search| search.query == query) { self.search = None; } } } } pub fn view(&self) -> Element<'_, Message> { let status = match self.last_run { Some(id) => format!("Opened \"{id}\""), None => "Type to search files. Matches stream in from a background task.".to_owned(), }; column![ command(&self.command) .placeholder("Search files and actions...") .loading(self.search.is_some()) .height(300) .on_event(Message::Command), text(status).size(14), ] .spacing(12) .width(Length::Fixed(420.0)) .into() } pub fn subscription(&self) -> Subscription { let search = match &self.search { Some(search) => Subscription::run_with(search.clone(), Search::run), None => Subscription::none(), }; Subscription::batch([command::subscription().map(Message::Command), search]) } } /// A simulated remote search that only holds a sender, like a worker would. /// A new query replaces the subscription, which stops the old search. #[derive(Debug, Clone)] struct Search { query: String, sender: command::Sender<&'static str>, } impl Hash for Search { fn hash(&self, state: &mut H) { self.query.hash(state); } } impl Search { fn run(&self) -> impl Stream + use<> { let query = self.query.clone(); let mut sender = self.sender.clone(); stream::once(async move { let found: Vec<&'static str> = FILES .iter() .copied() .filter(|file| score(&query, file).is_some()) .collect(); for chunk in found.chunks(2) { Delay::new(Duration::from_millis(400)).await; let files = chunk .iter() .map(|&file| item(file, file).icon(lucide!(File))); let _ = sender.send(results(query.as_str(), "Files", files)).await; } Message::SearchFinished(query) }) } } ``` ### Command palette The list in a dialog, opened by a button or Ctrl+K. Escape clears the query, then closes the palette; Ctrl+K passes through the dialog to close it too. ```rust use iced::widget::{column, container, text}; use iced::{Alignment, Element, Subscription}; use iced_cube::keys::{self, Chord}; use iced_cube::navigation::command::{self, Event, Output, State, command, group, item}; use iced_cube::overlay::dialog::{self, dialog}; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; #[derive(Debug, Clone)] pub enum Message { Open, Close, Command(command::Event<&'static str>), Key(keys::Event), } #[derive(Debug)] pub struct Example { open: bool, command: State<&'static str>, last_run: Option<&'static str>, } impl Default for Example { fn default() -> Self { Self { open: true, command: palette(), last_run: None, } } } fn palette() -> State<&'static str> { State::new([ group( "Pages", [ item("home", "Home").icon(lucide!(House)), item("inbox", "Inbox").icon(lucide!(Inbox)), item("settings", "Settings") .icon(lucide!(Settings)) .keywords(["preferences"]), ], ), group( "Actions", [ item("new-file", "New file") .icon(lucide!(FilePlus)) .shortcut("Ctrl+N"), item("theme", "Toggle theme").icon(lucide!(SunMoon)), ], ), ]) .with_visible_rows(5) } /// Opens the palette from anywhere, and closes it again while it is open. fn toggle() -> Chord { Chord::character('k').command() } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Open => self.show(), Message::Close => self.open = false, Message::Key(key) if toggle().matches(&key.key, key.modifiers) => { if self.open { self.open = false; } else { self.show(); } } Message::Key(_) => {} Message::Command(event) => match self.command.update(event) { Some(Output::Activated(id)) => { self.last_run = Some(id); self.open = false; } Some(Output::Closed) => self.open = false, _ => {} }, } } fn show(&mut self) { self.open = true; let _ = self.command.update(Event::Input(String::new())); } pub fn view(&self) -> Element<'_, Message> { let status = match self.last_run { Some(id) => format!("Ran \"{id}\""), None => "Press Ctrl+K anywhere to search pages and actions.".to_owned(), }; let base = column![ button("Open command palette") .icon(lucide!(Search)) .variant(Variant::Outline) .on_press(Message::Open), text(status).size(14), ] .spacing(12) .align_x(Alignment::Center); dialog(container(base).padding(24)) .open(self.open) .title("Command palette") .body( command(&self.command) .placeholder("Search pages and actions...") .max_height(280.0) .on_event(Message::Command), ) .size(dialog::Size::Sm) .close_button(false) .pass_through([toggle()]) .on_dismiss(Message::Close) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `State::new(groups)`: Holds the groups of items and highlights the first enabled one. - `group(label, items)`: Items under a heading. An empty label shows no heading. - `item(id, label)`: Creates an item. Add .icon(glyph), .shortcut(text), .keywords([...]), .disabled(true) or .destructive(true). - `.with_visible_rows(n)`: How many rows show at once (eight by default). The window follows the highlight and scrolls with the wheel. - `state.update(Event)`: Handles typing, moving, activating, closing and results from the channel. Returns an Output. - `Output`: Ready(sender) once the channel is open, Search(query) when the query changes, Activated(id) when an item is chosen, and Closed on Escape with an empty query. - `state.results() / state.highlighted() / state.query()`: The ranked results with their group labels, the highlighted item and the current query. - `state.shows_highlight() / state.is_focused()`: Whether the highlighted row is drawn highlighted, which it is while the field has focus or once the user has moved the highlight, and whether the field has focus. - `command::score(query, text)`: The matcher: case-insensitive, in-order letters, preferring exact matches, prefixes and word starts. None when there is no match. - `command::subscription()`: Owns the results channel. Emits Ready(sender) once, then batches of up to BATCH_SIZE (32). - `command::results(query, group, items)`: What a producer sends. Results for any query but the current one are dropped, and the rest are shown in arrival order under their group. - `command(&state)`: Renders the search field and results. It renders disabled until .on_event is set. - `.on_event(f) / .placeholder(text) / .empty(text)`: Maps events to your message, and sets the field and empty-state text. - `.loading(bool)`: Shows "Searching..." while a background search is running. - `.width(length) / .height(length) / .id(id)`: The width fills by default. A fixed height stops the list resizing as results change. - `.max_height(f32)`: Caps the height while the list still shrinks to fit a few results, as a palette should. Keep with_visible_rows within the cap, since rows past it are clipped. - `.keymap(keymap)`: Replaces the default keymap. The list resolves its own key presses while its search field has focus. - `command::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.event(&state)`: Turns an Action into the Event it sends, or None when it would do nothing, for keys the app routes itself. ## Keyboard - ArrowDown: `Next`. Highlights the next enabled result, wrapping at the end. - ArrowUp: `Previous`. Highlights the previous enabled result, wrapping at the start. - Home: `First`. Highlights the first enabled result. - End: `Last`. Highlights the last enabled result. - Enter: `Activate`. Activates the highlighted result. - Escape: `Close`. Clears the query, or closes the list when the query is empty. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/command-palette.md - https://sam-ruff.github.io/iced-cube/docs/components/combobox.md - https://sam-ruff.github.io/iced-cube/docs/components/dialog.md - https://sam-ruff.github.io/iced-cube/docs/components/tabs.md --- # Breadcrumb > Shows where the current view sits in a hierarchy, with a link to each level above it. ```rust use iced_cube::{breadcrumb, crumb, lucide}; use iced_cube::navigation::breadcrumb::Separator; ``` ## Usage The app holds the path and builds the trail from it on every view, giving each level above the current one a message that opens it. The breadcrumb only draws the trail, so the levels can be folders, settings sections or records. On a narrow screen the trail stays on one line by hiding more of its middle behind the ellipsis, down to the root and the current view, and only wraps if even that does not fit. Labels are never squeezed. Once someone expands the trail, `.collapse_to_fit(false)` shows every crumb and lets it wrap instead. For deep hierarchies, `.max_items` keeps the root and the levels nearest the current view, which are the ones people go back to most, and `.on_expand` lets them see the rest. The keymap's actions return an index rather than an event, because the app owns the path: `action.target(len)` says which crumb to open, as the keyboard example shows. ## Examples ### Default A folder path with a home icon. Clicking a level opens it and trims the trail. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::{breadcrumb, crumb, lucide}; const PATH: [&str; 4] = ["Home", "Documents", "Invoices", "2024"]; #[derive(Debug, Clone)] pub enum Message { Open(usize), } #[derive(Debug)] pub struct Example { depth: usize, } impl Default for Example { fn default() -> Self { Self { depth: PATH.len() } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Open(index) = message; self.depth = index + 1; } pub fn view(&self) -> Element<'_, Message> { let crumbs = PATH[..self.depth].iter().enumerate().map(|(index, name)| { let crumb = crumb(*name).on_press(Message::Open(index)); if index == 0 { crumb.icon(lucide!(House)) } else { crumb } }); let folder = format!("Viewing {}", PATH[..self.depth].join("/")); column![breadcrumb(crumbs), text(folder).size(14)] .spacing(12) .width(Length::Fill) .max_width(360) .into() } } ``` ### Collapsed A long trail with slash separators keeps its first and last levels. The ellipsis shows the rest. ```rust use iced::widget::column; use iced::{Element, Length}; use iced_cube::navigation::breadcrumb::Separator; use iced_cube::{breadcrumb, crumb}; const TRAIL: [&str; 6] = [ "Workspace", "Engineering", "Platform", "Services", "Billing", "Settings", ]; #[derive(Debug, Clone)] pub enum Message { Open(usize), Expand, } #[derive(Debug, Default)] pub struct Example { expanded: bool, } impl Example { pub fn update(&mut self, message: Message) { // Opening a level collapses the trail again. self.expanded = matches!(message, Message::Expand); } pub fn view(&self) -> Element<'_, Message> { let crumbs = TRAIL .iter() .enumerate() .map(|(index, name)| crumb(*name).on_press(Message::Open(index))); let mut trail = breadcrumb(crumbs).separator(Separator::Slash); trail = if self.expanded { trail.collapse_to_fit(false) } else { trail.max_items(3).on_expand(Message::Expand) }; column![trail].width(Length::Fill).max_width(420).into() } } ``` ### Keyboard shortcuts Alt+Up goes to the parent level and Alt+Shift+Up to the root, through the default keymap. ```rust use iced::widget::{column, text}; use iced::{Element, Length, Subscription}; use iced_cube::keys::{self, Keymap}; use iced_cube::navigation::breadcrumb::{self, Action}; use iced_cube::{crumb, lucide}; const PATH: [&str; 5] = ["Library", "Music", "Albums", "1997", "Disc one"]; #[derive(Debug, Clone)] pub enum Message { Open(usize), Key(keys::Event), } #[derive(Debug)] pub struct Example { depth: usize, keymap: Keymap, } impl Default for Example { fn default() -> Self { Self { depth: PATH.len(), keymap: breadcrumb::default_keymap(), } } } impl Example { pub fn update(&mut self, message: Message) { let target = match message { Message::Open(index) => Some(index), Message::Key(key) => self .keymap .resolve_event(&key) .and_then(|action| action.target(self.depth)), }; if let Some(index) = target { self.depth = index + 1; } } pub fn view(&self) -> Element<'_, Message> { let crumbs = PATH[..self.depth].iter().enumerate().map(|(index, name)| { let crumb = crumb(*name).on_press(Message::Open(index)); if index == 0 { crumb.icon(lucide!(Library)) } else { crumb } }); column![ breadcrumb::breadcrumb(crumbs), text("Alt+Up goes up a level and Alt+Shift+Up to the top.").size(14), ] .spacing(12) .width(Length::Fill) .max_width(380) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `breadcrumb(crumbs)`: Creates a trail from the root to the current view. The last crumb is the current view and never responds to clicks. - `crumb(label)`: Creates a crumb. It is plain text until it has a message. - `.on_press(message) / .on_press_maybe(option)`: Makes a crumb a link that sends the message. - `.icon(glyph)`: Adds a Lucide icon before a crumb's label, such as a house for the root. - `.separator(Separator)`: Chevron or Slash between crumbs. Defaults to Chevron. - `.max_items(n)`: Shows at most n crumbs: the first and the last n - 1, with an ellipsis for the rest. n is at least 2. - `.on_expand(message)`: Makes the ellipsis a button that sends the message, such as one that shows the whole trail. - `.collapse_to_fit(bool)`: Whether a trail too wide for one line hides more of its middle until it fits. Defaults to true. Turn it off to show every crumb, wrapping onto more lines. - `breadcrumb::fit_limits(len, max)`: The crumb limits a trail tries, longest first, when collapsing to fit. - `breadcrumb::slots(len, max)`: The crumbs and ellipsis a trail of len shows for a limit, for custom layouts. - `breadcrumb::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.target(len)`: The index of the crumb a resolved Action (Parent or Root) goes to in a trail of len, or None at the top. ## Keyboard - Alt+ArrowUp: `Parent`. Goes up one level, to the crumb before the current one. - Alt+Shift+ArrowUp: `Root`. Goes to the first crumb. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/tabs.md - https://sam-ruff.github.io/iced-cube/docs/components/pagination.md --- # Sidebar > Persistent app navigation down one side of the window, which folds to an icon rail and becomes a drawer on small screens. ```rust use iced_cube::navigation::sidebar::{self, Event, Output, State, group, item, menu_button, sidebar}; ``` ## Usage The app owns the state: which item is active, which groups and parents are open, whether the sidebar is folded to a rail, and whether the drawer is open. Clicking a parent item opens its children instead of activating it, and in the rail it expands the sidebar first. The item that holds the page keeps the accent background, and a closed parent keeps it while one of its children is active. The sidebar is drawn on a surface a shade off the page, with a one pixel edge, so it sits comfortably beside cards and other content. Rows use the same look as menus and lists. ## Small screens With `.content(...)`, the sidebar notices when its width falls below the breakpoint and sends `Event::Narrow(true)`. From then on it is an off-canvas drawer: a bar above the page holds the trigger, and the drawer slides over a scrim. A tap on the scrim, Escape or choosing an item closes it, and widening the window closes it too. `Event::Toggle` does whichever fits, so one trigger button works at every size. ## Keyboard focus iced 0.14 has no general focus model, so the sidebar takes focus the way a text field does: a press inside it gives it focus, and a press outside or Escape takes it away. An open drawer always has focus. While focused, the sidebar resolves its own keymap and captures the keys it uses. Ctrl+B is meant to work anywhere, so route `keys::subscription` through `state.shortcut`, which only resolves that one action. Some browsers keep Ctrl+B for themselves, so the rail story also has a button. ## Examples ### Default A workspace switcher, groups with icons and a badge, a parent with nested items, a collapsible group with a disabled item, and a user menu in the footer. On a phone it becomes a drawer. ```rust use iced::widget::{column, container, row, text}; use iced::{Alignment, Element, Length}; use iced_cube::dropdown_menu::{self, Output as MenuOutput, dropdown_menu}; use iced_cube::navigation::sidebar::{self, Event, State, group, item, menu_button, sidebar}; use iced_cube::overlay::anchored::Side; use iced_cube::theme::{semibold, text_size}; use iced_cube::{card, lucide, separator, vertical_separator}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { Dashboard, Inbox, Projects, Website, Mobile, Calendar, General, Billing, Team, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Workspace { Acme, Globex, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Account { Profile, SignOut, } #[derive(Debug, Clone)] pub enum Message { Sidebar(Event), Workspaces(dropdown_menu::Event), Account(dropdown_menu::Event), } #[derive(Debug)] pub struct Example { sidebar: State, workspaces: dropdown_menu::State, account: dropdown_menu::State, workspace: Workspace, } impl Default for Example { fn default() -> Self { let navigation = State::new([ group( "Platform", [ item(Page::Dashboard, "Dashboard").icon(lucide!(LayoutDashboard)), item(Page::Inbox, "Inbox").icon(lucide!(Inbox)).badge("12"), item(Page::Projects, "Projects") .icon(lucide!(FolderOpen)) .children([ item(Page::Website, "Website"), item(Page::Mobile, "Mobile app"), ]), item(Page::Calendar, "Calendar").icon(lucide!(Calendar)), ], ), group( "Settings", [ item(Page::General, "General").icon(lucide!(Settings)), item(Page::Billing, "Billing").icon(lucide!(CreditCard)), item(Page::Team, "Team").icon(lucide!(Users)).disabled(true), ], ) .collapsible(true), ]) .with_active(Page::Website); Self { sidebar: navigation, workspaces: dropdown_menu::State::new([ dropdown_menu::group_label("Workspaces"), dropdown_menu::radio_item(Workspace::Acme, "Acme Inc", true), dropdown_menu::radio_item(Workspace::Globex, "Globex", false), ]), account: dropdown_menu::State::new([ dropdown_menu::item(Account::Profile, "Profile").icon(lucide!(User)), dropdown_menu::separator(), dropdown_menu::item(Account::SignOut, "Sign out").icon(lucide!(LogOut)), ]), workspace: Workspace::Acme, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Sidebar(event) => { let _ = self.sidebar.update(event); } Message::Workspaces(event) => { if let Some(MenuOutput::Selected(workspace)) = self.workspaces.update(event) { self.workspace = workspace; } } Message::Account(event) => { let _ = self.account.update(event); } } } pub fn view(&self) -> Element<'_, Message> { let compact = self.sidebar.is_compact(); let name = match self.workspace { Workspace::Acme => "Acme Inc", Workspace::Globex => "Globex", }; let switcher = dropdown_menu( &self.workspaces, menu_button(name) .subtitle("Enterprise") .icon(lucide!(Command)) .compact(compact) .on_press(Message::Workspaces(dropdown_menu::Event::Toggle)), ) .side(if compact { Side::Right } else { Side::Bottom }) .on_event(Message::Workspaces); let user = dropdown_menu( &self.account, menu_button("Sam Taylor") .subtitle("sam@example.com") .initials("ST") .compact(compact) .on_press(Message::Account(dropdown_menu::Event::Toggle)), ) .side(if compact { Side::Right } else { Side::Top }) .on_event(Message::Account); sidebar(&self.sidebar) .header(switcher) .footer(user) .content(self.page()) .trigger(false) .on_event(Message::Sidebar) .into() } fn page(&self) -> Element<'_, Message> { let title = self .sidebar .active() .and_then(|page| self.sidebar.item(page)) .map_or("Dashboard", |item| item.label.as_str()); let bar = row![ sidebar::trigger(Some(Message::Sidebar(Event::Toggle))), container(vertical_separator()).height(16), text(title) .size(text_size::SM) .font(semibold()) .wrapping(text::Wrapping::None), ] .spacing(8) .align_y(Alignment::Center) .padding([6, 12]); let body = card() .title(title) .description("Everything in this project, in one place.") .body( text("Choose a page on the left. The button above collapses the sidebar to icons.") .size(text_size::SM), ) .width(Length::Fill); column![bar, separator(), container(body).padding(16)] .width(Length::Fill) .into() } } ``` ### Collapsed to a rail Icons only, with the label in a tooltip. Ctrl+B toggles the rail through the default keymap, routed from keys::subscription with State::shortcut. ```rust use iced::widget::{column, container, row, text}; use iced::{Alignment, Element, Length, Subscription}; use iced_cube::keys::{self, Keymap}; use iced_cube::lucide; use iced_cube::navigation::sidebar::{ self, Action, Event, State, group, item, menu_button, sidebar, }; use iced_cube::theme::{semibold, text_size}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { Home, Inbox, Calendar, Reports, Settings, } #[derive(Debug, Clone)] pub enum Message { Sidebar(Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { sidebar: State, keymap: Keymap, } impl Default for Example { fn default() -> Self { Self { sidebar: State::new([ group( "Workspace", [ item(Page::Home, "Home").icon(lucide!(House)), item(Page::Inbox, "Inbox").icon(lucide!(Inbox)).badge("3"), item(Page::Calendar, "Calendar").icon(lucide!(Calendar)), item(Page::Reports, "Reports").icon(lucide!(ChartColumn)), ], ), group( "", [item(Page::Settings, "Settings").icon(lucide!(Settings))], ), ]) .with_active(Page::Inbox) .with_rail(true), keymap: sidebar::default_keymap(), } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Sidebar(event) => Some(event), Message::Key(key) => self.sidebar.shortcut(&self.keymap, &key), }; if let Some(event) = event { let _ = self.sidebar.update(event); } } pub fn view(&self) -> Element<'_, Message> { let title = self .sidebar .active() .and_then(|page| self.sidebar.item(page)) .map_or("", |item| item.label.as_str()); let page = column![ row![ sidebar::trigger(Some(Message::Sidebar(Event::Toggle))), text(title).size(text_size::SM).font(semibold()), ] .spacing(8) .align_y(Alignment::Center), text("Ctrl+B or the button toggles the rail. In the rail, hover an icon for its label. Click the sidebar, then Up, Down and Enter move through it, and Escape leaves.") .size(text_size::SM), ] .spacing(12) .padding(16); sidebar(&self.sidebar) .header( menu_button("Northwind") .icon(lucide!(Command)) .compact(self.sidebar.is_compact()), ) .content(container(page).width(Length::Fill)) .trigger(false) .keymap(self.keymap.clone()) .on_event(Message::Sidebar) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ### Drawer on narrow windows Below the breakpoint the sidebar slides over the page on a scrim, opened by the trigger in the bar above the content. This example sets the breakpoint so high that it always shows the drawer. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::lucide; use iced_cube::navigation::sidebar::{Event, State, group, item, sidebar}; use iced_cube::theme::{semibold, text_size}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { Home, Orders, Customers, Products, Settings, } #[derive(Debug, Clone)] pub enum Message { Sidebar(Event), } #[derive(Debug)] pub struct Example { sidebar: State, } impl Default for Example { fn default() -> Self { let mut sidebar = State::new([ group( "Store", [ item(Page::Home, "Home").icon(lucide!(House)), item(Page::Orders, "Orders") .icon(lucide!(Package)) .badge("8"), item(Page::Customers, "Customers").icon(lucide!(Users)), item(Page::Products, "Products").icon(lucide!(Tag)), ], ), group( "", [item(Page::Settings, "Settings").icon(lucide!(Settings))], ), ]) .with_active(Page::Orders); let _ = sidebar.update(Event::Narrow(true)); let _ = sidebar.update(Event::OpenDrawer); Self { sidebar } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Sidebar(event) = message; let _ = self.sidebar.update(event); } pub fn view(&self) -> Element<'_, Message> { let title = self .sidebar .active() .and_then(|page| self.sidebar.item(page)) .map_or("", |item| item.label.as_str()); let page = column![ text(title).size(text_size::LG).font(semibold()), text("The menu button opens the drawer. Tap the dimmed page, press Escape or choose a page to close it.") .size(text_size::SM), ] .spacing(8) .padding(16) .width(Length::Fill); // Every window counts as narrow here, so the drawer shows on the // desktop too. Apps usually keep the default breakpoint. sidebar(&self.sidebar) .content(page) .breakpoint(f32::INFINITY) .on_event(Message::Sidebar) .into() } } ``` ### In a resizable panel The sidebar fills a collapsible panel beside the page. Dragging the panel below its minimum snaps it to the width of the rail. ```rust use iced::widget::{column, container, text}; use iced::{Element, Length}; use iced_cube::layout::resizable_panel::{self, Extent, Output, panel, resizable_panel}; use iced_cube::lucide; use iced_cube::navigation::sidebar::{Event, RAIL_WIDTH, State, group, item, menu_button, sidebar}; use iced_cube::theme::{semibold, text_size}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { Overview, Deployments, Logs, Settings, } #[derive(Debug, Clone)] pub enum Message { Sidebar(Event), Panels(resizable_panel::Event), } #[derive(Debug)] pub struct Example { sidebar: State, panels: resizable_panel::State, } impl Default for Example { fn default() -> Self { Self { sidebar: State::new([group( "Project", [ item(Page::Overview, "Overview").icon(lucide!(LayoutDashboard)), item(Page::Deployments, "Deployments") .icon(lucide!(Rocket)) .badge("2"), item(Page::Logs, "Logs").icon(lucide!(Terminal)), item(Page::Settings, "Settings").icon(lucide!(Settings)), ], )]) .with_active(Page::Overview), // Collapsing the panel to the rail's width shows the rail. panels: resizable_panel::State::new([ panel(Extent::Pixels(220.0)) .min(Extent::Pixels(180.0)) .max(Extent::Fraction(0.5)) .collapsible(true) .collapsed_size(Extent::Pixels(RAIL_WIDTH)), panel(Extent::Fraction(1.0)).min(Extent::Pixels(120.0)), ]), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Sidebar(Event::Toggle | Event::ToggleRail) => { let _ = self.panels.update(resizable_panel::Event::Toggle(0)); self.sync_rail(); } Message::Sidebar(event) => { let _ = self.sidebar.update(event); } Message::Panels(event) => { if let Some(Output::Collapsed(0) | Output::Expanded(0)) = self.panels.update(event) { self.sync_rail(); } } } } fn sync_rail(&mut self) { let rail = self.panels.is_collapsed(0); let _ = self.sidebar.update(Event::SetRail(rail)); } pub fn view(&self) -> Element<'_, Message> { let navigation = sidebar(&self.sidebar) .header( menu_button("Aurora") .subtitle("Production") .icon(lucide!(Cloud)) .compact(self.sidebar.is_compact()), ) .width(Length::Fill) .on_event(Message::Sidebar); let title = self .sidebar .active() .and_then(|page| self.sidebar.item(page)) .map_or("", |item| item.label.as_str()); let page = container( column![ text(title).size(text_size::LG).font(semibold()), text("Drag the edge of the sidebar to resize it. Drag it past the minimum and it snaps to the rail, and Ctrl+B on the focused sidebar does the same.") .size(text_size::SM), ] .spacing(8), ) .padding(16) .width(Length::Fill) .height(Length::Fill); resizable_panel(&self.panels, [navigation.into(), page.into()]) .on_event(Message::Panels) .into() } } ``` ## API - `group(label, items)`: A labelled group of items. An empty label draws no heading. Add .collapsible(true) to let the heading open and close it. - `item(id, label)`: A navigation item. Add .icon(glyph), .badge(text), .disabled(true) or .children(items) for one level of nested items. - `State::new(groups)`: Holds the navigation with every group open and nothing active. - `.with_active(id) / .with_rail(bool)`: Starts on an item, opening its parent, or collapsed to the icon rail. - `state.update(Event)`: Handles clicks, keyboard navigation, the rail and the drawer. Returns Output::Activated(id) when an item is chosen. - `state.shortcut(&keymap, &key)`: Resolves the app-wide Ctrl+B from keys::subscription. The sidebar handles its other keys while it has focus. - `state.active() / state.item(id) / state.is_compact()`: The active item, an item's details, and whether the rail is showing, for the header and footer buttons. - `sidebar(&state)`: Renders the navigation. Items render disabled until .on_event is set. - `.header(element) / .footer(element)`: Content above and below the groups, such as a workspace switcher and the signed in user. - `.content(element)`: The page beside the sidebar. With content, the sidebar becomes a drawer below the breakpoint; without it only the panel is drawn. - `.breakpoint(width)`: The window width below which the sidebar becomes a drawer. Defaults to 640. - `.trigger(bool)`: Whether narrow windows get a bar holding the drawer's trigger. Turn it off to put sidebar::trigger in your own header. - `.width(length)`: The expanded width, 256 by default. Use Length::Fill inside a resizable panel. - `.keymap(Keymap) / sidebar::default_keymap()`: The shortcuts the focused sidebar resolves. - `sidebar::trigger(message)`: The icon button that toggles the rail, or the drawer on a narrow window. Send Event::Toggle from it. - `menu_button(title)`: A header or footer button with .subtitle, .icon or .initials, and .compact(state.is_compact()) for the rail. Wrap it in a dropdown menu for a switcher or a user menu. ## Keyboard - ArrowDown: `Next`. Highlights the next row, wrapping. - ArrowUp: `Previous`. Highlights the previous row, wrapping. - Home: `First`. Highlights the first row. - End: `Last`. Highlights the last row. - Enter or Space: `Activate`. Activates the highlighted item or opens and closes its group. - ArrowRight: `Expand`. Opens the highlighted group or parent item, or moves into it. - ArrowLeft: `Collapse`. Closes the highlighted group or parent item, or moves out to it. - Ctrl+B: `ToggleRail`. Collapses the sidebar to an icon rail and back, or toggles the drawer on a narrow window. - Escape: `Close`. Closes the drawer, or leaves the sidebar. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/resizable-panel.md - https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/tabs.md --- # Document tabs > A strip of open documents above an editor, with unsaved changes, pinning, reordering and a menu for tabs that do not fit. ```rust use iced_cube::navigation::document_tabs::{self, Event, MenuItem, Output, State, document, document_tabs}; ``` ## Usage Document tabs are the strip of open files above an editor. The app owns a `document_tabs::State` with the documents in order and routes the strip's events into it. The strip only switches between documents: the app renders the active one below it, and the active tab takes the page background so it joins that content. Closing never happens by itself. The close button, a middle click, `Ctrl+W` and the context menu all return `Output::CloseRequested` with the ids to close, so the app can save, or ask with an alert dialog when a document has unsaved changes, before it calls `state.close(&ids)`. The confirmation example shows the pattern. Each tab reserves a slot after its label. The active tab shows its close button there, other tabs show it on hover, and a document with unsaved changes shows a dot that turns into the close button under the pointer, so the label never moves. Pinned tabs sit before the others, show only their icon (or a short label when they have none), have no close button, and are left alone by Close others, Close to the right, Close saved and Close all. Dragging a tab moves it, with a line marking where it will land, and a pinned tab only moves among pinned tabs. A tab's context menu only lists what would do something on it, and never two close items that would close exactly the same tabs; Close and Close all are kept over the others. So Close saved, which closes the tabs without unsaved changes, appears only while some of those tabs have unsaved changes and some do not. A finger activates a tab when it lifts without having moved, so swiping the strip or holding a tab for its menu never switches tabs. For a document the app always keeps open, such as a list the other tabs are opened from, use `.pinned(true).closable(false).locked(true)`: it then has no close button, no Close, Pin or Unpin item, and no close gesture or shortcut asks to close it. When the tabs do not fit, the pinned tabs stay at the start of the strip and only the others scroll sideways, with the wheel or a finger, in the room after them. A button at the end of the strip lists every tab. When pinned tabs would take more than half that room, they scroll with the others instead, so a narrow window always has room to show the active tab. The strip keeps the active tab in view when it first appears, when the active tab changes and when the window is resized. The list opens below its button, marks the active tab and scrolls to it. Labels stay on one line, in the strip and in the list, and end in an ellipsis when they do not fit. A tab's context menu opens below the tab, whether from a right-click, a long press or the keyboard, and scrolls when the window is too short for it. `Ctrl+Tab` walks the documents in the order they were last used, as editors do: each press goes one further back until Ctrl is released, which the app learns from `document_tabs::cycle_end()`. `Ctrl+PageDown` and `Ctrl+PageUp` move through the strip in order. The segmented [tabs](https://sam-ruff.github.io/iced-cube/docs/components/tabs.md) also bind `Ctrl+Tab` and `Ctrl+Shift+Tab`, so an app showing both should unbind them from one of them, usually the segmented tabs. Browsers keep these chords for their own tabs, which is why the keyboard example adds chords such as `Ctrl+]` that reach the page. `Action::OpenMenu`, which opens the active tab's context menu, has no default chord. The strip never has keyboard focus, so an app-wide `Shift+F10` would also open any other [context menu](https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md) on screen. Bind it with `default_keymap().bind(Chord::named(Named::F10).shift(), Action::OpenMenu)` when the tab strip is the only context menu, or resolve it only while the editor has focus. See [Keyboard shortcuts](https://sam-ruff.github.io/iced-cube/docs/keyboard.md) for how components share keys. ## Examples ### Editor Open files with icons, a pinned README, unsaved changes shown as dots and a new tab button. Drag a tab to move it, middle-click to close it, or right-click for more, including an app item that copies the path. ```rust use iced::widget::{column, container, row, text}; use iced::{Border, Element, Length, Theme, border}; use iced_cube::lucide; use iced_cube::navigation::document_tabs::{ Event, MenuItem, Output, State, document, document_tabs, }; use iced_cube::overlay::dropdown_menu::item; use iced_cube::theme::{Tokens, radius, text_size}; const COPY_PATH: MenuItem = MenuItem::Custom("copy-path"); #[derive(Debug, Clone)] pub enum Message { Tabs(Event), } #[derive(Debug)] pub struct Example { tabs: State, next: u32, status: String, } impl Default for Example { fn default() -> Self { let tabs = State::new([ document(1, "README.md") .icon(lucide!(BookOpen)) .pinned(true), document(2, "main.rs").icon(lucide!(FileCode)), document(3, "parser.rs") .icon(lucide!(FileCode)) .modified(true), document(4, "Cargo.toml").icon(lucide!(FileCog)), document(5, "notes.md") .icon(lucide!(FileText)) .modified(true), ]) .with_active(3) .with_menu_items([item(COPY_PATH, "Copy path")]); Self { tabs, next: 6, status: "Right-click or press and hold a tab for more.".into(), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Tabs(event) = message; match self.tabs.update(event) { Some(Output::CloseRequested(ids)) => { let _ = self.tabs.close(&ids); } Some(Output::NewTab) => { let label = format!("untitled-{}.md", self.next); self.tabs .open(document(self.next, label).icon(lucide!(FileText))); self.next += 1; } Some(Output::Custom(_, id)) => { let label = self.tabs.document(id).map_or("", |doc| doc.label.as_str()); self.status = format!("Copied src/{label}"); } _ => {} } } pub fn view(&self) -> Element<'_, Message> { let lines = self.tabs.active().map_or(&[][..], contents); let numbered = lines.iter().enumerate().map(|(index, line)| { row![ text(index + 1).size(text_size::SM).width(24).style(muted), text(*line).size(text_size::SM), ] .into() }); let editor = container(column(numbered).spacing(4)) .padding([12, 16]) .width(Length::Fill) .height(Length::Fill) .style(page); let frame = column![ document_tabs(&self.tabs) .new_tab(true) .on_event(Message::Tabs), editor, ]; column![ container(frame) .padding([4, 4]) .width(Length::Fill) .height(240) .style(window), text(&self.status).size(text_size::SM).style(muted), ] .spacing(12) .into() } } fn contents(id: u32) -> &'static [&'static str] { match id { 1 => &["# Parser", "", "A small expression parser."], 2 => &["mod parser;", "", "fn main() {", " parser::run();", "}"], 3 => &[ "pub fn run() {", " let tokens = lex(\"1 + 2\");", " let tree = parse(&tokens);", " println!(\"{tree:?}\");", "}", ], 4 => &["[package]", "name = \"parser\"", "edition = \"2024\""], 5 => &["Handle unary minus.", "Report the column of errors."], _ => &[""], } } fn window(theme: &Theme) -> container::Style { let tokens = Tokens::of(theme); container::Style { background: Some(tokens.muted.into()), border: Border { color: tokens.border, width: 1.0, radius: radius::LG.into(), }, ..container::Style::default() } } fn page(theme: &Theme) -> container::Style { container::Style { background: Some(Tokens::of(theme).background.into()), border: border::rounded(border::bottom(radius::SM)), ..container::Style::default() } } fn muted(theme: &Theme) -> text::Style { text::Style { color: Some(Tokens::of(theme).muted_foreground), } } ``` ### Many tabs Tabs that do not fit scroll sideways, keeping the active one in view, and a button at the end lists them all. ```rust use iced::widget::{column, container, text}; use iced::{Border, Element, Length, Theme, border}; use iced_cube::lucide; use iced_cube::navigation::document_tabs::{Event, Output, State, document, document_tabs}; use iced_cube::theme::{Tokens, radius, text_size}; const CHAPTERS: [&str; 12] = [ "Introduction", "Getting started", "Installation", "Configuration", "Command line", "Plugins", "Theming", "Keyboard shortcuts", "Accessibility", "Deployment", "Troubleshooting", "Release notes", ]; #[derive(Debug, Clone)] pub enum Message { Tabs(Event), } #[derive(Debug)] pub struct Example { tabs: State, } impl Default for Example { fn default() -> Self { let documents = CHAPTERS.iter().enumerate().map(|(index, title)| { document(index, format!("{title}.md")) .icon(lucide!(FileText)) .modified(index == 4) }); Self { tabs: State::new(documents).with_active(9), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Tabs(event) = message; if let Some(Output::CloseRequested(ids)) = self.tabs.update(event) { let _ = self.tabs.close(&ids); } } pub fn view(&self) -> Element<'_, Message> { let title = self .tabs .active() .and_then(|id| self.tabs.document(id)) .map_or("", |doc| doc.label.as_str()); let page = container( column![ text(title).size(text_size::SM), text("Scroll the strip with the wheel, or pick a tab from the list at its end.") .size(text_size::SM) .style(muted), ] .spacing(4), ) .padding([12, 16]) .width(Length::Fill) .height(Length::Fill) .style(page); container(column![ document_tabs(&self.tabs).on_event(Message::Tabs), page ]) .padding(4) .width(Length::Fill) .max_width(560) .height(140) .style(window) .into() } } fn window(theme: &Theme) -> container::Style { let tokens = Tokens::of(theme); container::Style { background: Some(tokens.muted.into()), border: Border { color: tokens.border, width: 1.0, radius: radius::LG.into(), }, ..container::Style::default() } } fn page(theme: &Theme) -> container::Style { container::Style { background: Some(Tokens::of(theme).background.into()), border: border::rounded(border::bottom(radius::SM)), ..container::Style::default() } } fn muted(theme: &Theme) -> text::Style { text::Style { color: Some(Tokens::of(theme).muted_foreground), } } ``` ### Confirm before closing Closing only asks. The app checks for unsaved changes, confirms with an alert dialog, then closes the tabs itself. ```rust use iced::widget::{column, container, text}; use iced::{Border, Element, Length, Theme, border}; use iced_cube::alert_dialog; use iced_cube::lucide; use iced_cube::navigation::document_tabs::{Event, Output, State, document, document_tabs}; use iced_cube::theme::{Tokens, radius, text_size}; #[derive(Debug, Clone)] pub enum Message { Tabs(Event), Discard, Cancel, } #[derive(Debug)] pub struct Example { tabs: State, /// Documents waiting to close while the user decides about unsaved ones. pending: Vec, } impl Default for Example { fn default() -> Self { Self { tabs: State::new([ document(1, "budget.csv").icon(lucide!(FileText)), document(2, "letter.md") .icon(lucide!(FileText)) .modified(true), document(3, "todo.md").icon(lucide!(FileText)), ]) .with_active(2), pending: vec![2], } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Tabs(event) => { let Some(Output::CloseRequested(ids)) = self.tabs.update(event) else { return; }; if self.unsaved(&ids).is_empty() { let _ = self.tabs.close(&ids); } else { self.pending = ids; } } Message::Discard => { let _ = self.tabs.close(&self.pending); self.pending.clear(); } Message::Cancel => self.pending.clear(), } } fn unsaved(&self, ids: &[u32]) -> Vec<&str> { ids.iter() .filter_map(|id| self.tabs.document(*id)) .filter(|doc| doc.modified) .map(|doc| doc.label.as_str()) .collect() } pub fn view(&self) -> Element<'_, Message> { let page = container( text("Close the modified tab to be asked first. Saved tabs close at once.") .size(text_size::SM) .style(muted), ) .padding([12, 16]) .width(Length::Fill) .height(Length::Fill) .style(page); let editor = container(column![ document_tabs(&self.tabs).on_event(Message::Tabs), page ]) .padding(4) .width(Length::Fill) .max_width(480) .height(160) .style(window); let unsaved = self.unsaved(&self.pending).join(", "); alert_dialog( container(editor).padding(24).center_x(Length::Fill), format!("Close {unsaved} without saving?"), "Your changes are lost if you close without saving.", ) .open(!self.pending.is_empty()) .confirm("Close without saving") .on_cancel(Message::Cancel) .on_confirm(Message::Discard) .into() } } fn window(theme: &Theme) -> container::Style { let tokens = Tokens::of(theme); container::Style { background: Some(tokens.muted.into()), border: Border { color: tokens.border, width: 1.0, radius: radius::LG.into(), }, ..container::Style::default() } } fn page(theme: &Theme) -> container::Style { container::Style { background: Some(Tokens::of(theme).background.into()), border: border::rounded(border::bottom(radius::SM)), ..container::Style::default() } } fn muted(theme: &Theme) -> text::Style { text::Style { color: Some(Tokens::of(theme).muted_foreground), } } ``` ### Keyboard shortcuts The default keymap with extra chords that reach a web page, since browsers keep Ctrl+Tab, Ctrl+W and Ctrl+PageDown for themselves. Closing a modified tab asks first. Releasing every modifier ends a walk through recent tabs. ```rust use iced::keyboard::key::Named; use iced::widget::{column, container, row, text}; use iced::{Border, Element, Length, Subscription, Theme, border}; use iced_cube::button; use iced_cube::keys::{self, Chord, Keymap}; use iced_cube::lucide; use iced_cube::navigation::document_tabs::{ self, Action, Event, Output, State, document, document_tabs, }; use iced_cube::primitives::button::{Size, Variant}; use iced_cube::theme::{Tokens, radius, text_size}; #[derive(Debug, Clone)] pub enum Message { Tabs(Event), Key(keys::Event), Discard, Cancel, } #[derive(Debug)] pub struct Example { tabs: State, keymap: Keymap, /// Documents waiting to close while the user decides about unsaved ones. pending: Vec, } impl Default for Example { fn default() -> Self { // Browsers keep Ctrl+Tab, Ctrl+W and Ctrl+PageDown for their own // tabs, so the example adds chords that reach the page. let keymap = document_tabs::default_keymap() .bind(Chord::character(']').ctrl(), Action::Next) .bind(Chord::character('[').ctrl(), Action::Previous) .bind(Chord::character('.').ctrl(), Action::MoveRight) .bind(Chord::character(',').ctrl(), Action::MoveLeft) .bind(Chord::named(Named::Backspace).ctrl(), Action::Close); Self { tabs: State::new([ document(1, "index.html").icon(lucide!(FileCode)), document(2, "styles.css").icon(lucide!(FileCode)), document(3, "app.js").icon(lucide!(FileCode)).modified(true), document(4, "favicon.svg").icon(lucide!(FileText)), ]), keymap, pending: Vec::new(), } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Tabs(event) => event, Message::Key(_) if !self.pending.is_empty() => return, Message::Key(key) => { let action = self.keymap.resolve_event(&key); let Some(event) = action.and_then(|action| action.event(&self.tabs)) else { return; }; event } Message::Discard => { let _ = self.tabs.close(&self.pending); self.pending.clear(); return; } Message::Cancel => { self.pending.clear(); return; } }; let Some(Output::CloseRequested(ids)) = self.tabs.update(event) else { return; }; if self.unsaved(&ids).is_empty() { let _ = self.tabs.close(&ids); } else { self.pending = ids; } } fn unsaved(&self, ids: &[u32]) -> Vec<&str> { ids.iter() .filter_map(|id| self.tabs.document(*id)) .filter(|doc| doc.modified) .map(|doc| doc.label.as_str()) .collect() } pub fn view(&self) -> Element<'_, Message> { let body: Element<'_, Message> = if self.pending.is_empty() { let recent: Vec<&str> = self .tabs .recent() .iter() .filter_map(|id| self.tabs.document(*id)) .map(|doc| doc.label.as_str()) .collect(); column![ text("Ctrl+[ and Ctrl+] switch tabs, Ctrl+, and Ctrl+. move one, Ctrl+Backspace closes. In the desktop app, Ctrl+Tab switches to the last used tab.") .size(text_size::SM), text(format!("Recently used: {}", recent.join(", "))) .size(text_size::SM) .style(muted), ] .spacing(8) .into() } else { let unsaved = self.unsaved(&self.pending).join(", "); column![ text(format!("Close {unsaved} without saving?")).size(text_size::SM), row![ button("Cancel") .variant(Variant::Outline) .size(Size::Sm) .on_press(Message::Cancel), button("Close without saving") .variant(Variant::Destructive) .size(Size::Sm) .on_press(Message::Discard), ] .spacing(8) .wrap(), ] .spacing(12) .into() }; let page = container(body) .padding([12, 16]) .width(Length::Fill) .height(Length::Fill) .style(page); container(column![ document_tabs(&self.tabs).on_event(Message::Tabs), page ]) .padding(4) .width(Length::Fill) .max_width(560) .height(200) .style(window) .into() } pub fn subscription(&self) -> Subscription { Subscription::batch([ keys::subscription().map(Message::Key), document_tabs::cycle_end().map(|end| Message::Tabs(end.into())), ]) } } fn window(theme: &Theme) -> container::Style { let tokens = Tokens::of(theme); container::Style { background: Some(tokens.muted.into()), border: Border { color: tokens.border, width: 1.0, radius: radius::LG.into(), }, ..container::Style::default() } } fn page(theme: &Theme) -> container::Style { container::Style { background: Some(Tokens::of(theme).background.into()), border: border::rounded(border::bottom(radius::SM)), ..container::Style::default() } } fn muted(theme: &Theme) -> text::Style { text::Style { color: Some(Tokens::of(theme).muted_foreground), } } ``` ## API - `State::new(documents)`: Opens the documents, pinned ones first, with the first one active. Ids must be unique. - `document(id, label)`: A saved, unpinned document. Add .icon(glyph), .modified(true) or .pinned(true). - `.closable(false)`: A document the user cannot close: no close button, no Close item, left out of Close others, Close to the right, Close saved and Close all, and Ctrl+W does nothing on it. - `.locked(true)`: Keeps a document pinned or unpinned as it is: its menu has no Pin or Unpin item and Event::Pin leaves it alone. It still moves within its group. - `.with_active(id) / .with_menu_items(entries)`: The document to start on, and app items added to every tab's context menu, built with dropdown_menu::item(MenuItem::Custom("name"), label). - `state.update(Event)`: Activates, walks recent documents, moves, pins and drives both menus. Returns Output::Activated(id), CloseRequested(ids), Moved { id, from, to }, Pinned(id, bool), NewTab or Custom(name, id). - `state.close(&ids)`: Closes documents. When the active one closes, the most recently used one left becomes active and is returned. - `state.open(document)`: Activates a document, adding it at the end of its group when it is not open yet. - `state.set_modified(id, bool) / state.set_label(id, label)`: Marks unsaved changes, or renames a tab. - `state.documents() / state.document(id) / state.active() / state.recent()`: The documents in strip order, one document, the active id, and the ids most recently used first. - `document_tabs(&state)`: Renders the strip. Tabs render disabled until .on_event is set. - `.new_tab(bool)`: Shows a new tab button after the last tab, and lets a double click on the empty strip ask for a new document. - `.max_label_width(f32)`: The widest a label grows before it ends in an ellipsis. Defaults to 200 pixels. - `.context_menu_keymap(keymap) / .overflow_keymap(keymap)`: Replaces the default keymap of the open tab context menu or of the list of every tab. - `document_tabs::cycle_end()`: A subscription that reports when every modifier is released, which ends a walk through recent documents. Map it with Event::from. - `document_tabs::default_keymap() / action.event(&state)`: The default shortcuts, and the Event a resolved Action sends. - `document_tabs::tab_style(tokens, active, hovered) / strip_style(tokens)`: The colours of one tab and of the strip behind it. ## Keyboard - Ctrl+Tab: `RecentNext`. Activates the previously used document. Pressed again while the modifier is held, goes further back. - Ctrl+Shift+Tab: `RecentPrevious`. Walks the recently used documents the other way. - Ctrl+PageDown: `Next`. Activates the tab to the right, wrapping at the end. - Ctrl+PageUp: `Previous`. Activates the tab to the left, wrapping at the start. - Ctrl+Shift+PageUp: `MoveLeft`. Moves the active tab one place left, within its group. - Ctrl+Shift+PageDown: `MoveRight`. Moves the active tab one place right, within its group. - Ctrl+W: `Close`. Asks to close the active document. - Ctrl+N: `New`. Asks for a new document. - Ctrl+1: `Tab1`. Activates the first tab. - Ctrl+2: `Tab2`. Activates the second tab. - Ctrl+3: `Tab3`. Activates the third tab. - Ctrl+4: `Tab4`. Activates the fourth tab. - Ctrl+5: `Tab5`. Activates the fifth tab. - Ctrl+6: `Tab6`. Activates the sixth tab. - Ctrl+7: `Tab7`. Activates the seventh tab. - Ctrl+8: `Tab8`. Activates the eighth tab. - Ctrl+9: `LastTab`. Activates the last tab. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/tabs.md - https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/dialog.md --- # Pagination > Moves through a long list one page at a time. ```rust use iced_cube::pagination; use iced_cube::navigation::pagination::{Event, State, Variant}; ``` ## Usage The app owns a `pagination::State` and routes events into it, then shows the slice of its list that `state.range(items, per_page)` returns. The control only moves the page, so the list can be anything from table rows to search results. The numbered layout always shows the first and last pages and the ones next to the current page, with a gap for the rest. Once there are enough pages for a gap, it always shows the same number of slots, so the buttons stay put under the pointer as the page changes. Page numbers of three or more digits widen their buttons a little. When the space is too narrow, as on a phone, it drops the neighbouring pages and then falls back to the compact layout. Use the compact layout under short lists, in panels and in dialogs, where the page count matters more than jumping to a particular page. ## Examples ### Page numbers The first, last and nearby pages, with gaps for the rest. On a narrow screen it drops pages to fit. ```rust use iced::widget::{column, text}; use iced::{Alignment, Element}; use iced_cube::navigation::pagination::{self, State, pagination}; const RESULTS: usize = 386; const PER_PAGE: usize = 20; #[derive(Debug, Clone)] pub enum Message { Page(pagination::Event), } #[derive(Debug)] pub struct Example { pages: State, } impl Default for Example { fn default() -> Self { Self { pages: State::for_items(RESULTS, PER_PAGE).with_page(6), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Page(event) = message; let _ = self.pages.update(event); } pub fn view(&self) -> Element<'_, Message> { let shown = self.pages.range(RESULTS, PER_PAGE); let summary = format!("Results {} to {} of {RESULTS}", shown.start + 1, shown.end); column![ pagination(&self.pages).on_event(Message::Page), text(summary).size(14), ] .spacing(12) .align_x(Alignment::Center) .into() } } ``` ### Compact A position label between first, previous, next and last buttons, paging a short list. ```rust use iced::widget::{column, row, space, text}; use iced::{Alignment, Element, Length}; use iced_cube::navigation::pagination::{self, State, Variant, pagination}; use iced_cube::primitives::button::Size; const INVOICES: [(&str, &str); 11] = [ ("INV-1041", "£120.00"), ("INV-1042", "£86.50"), ("INV-1043", "£240.00"), ("INV-1044", "£18.99"), ("INV-1045", "£302.10"), ("INV-1046", "£75.00"), ("INV-1047", "£64.20"), ("INV-1048", "£150.00"), ("INV-1049", "£9.99"), ("INV-1050", "£410.00"), ("INV-1051", "£33.40"), ]; const PER_PAGE: usize = 4; #[derive(Debug, Clone)] pub enum Message { Page(pagination::Event), } #[derive(Debug)] pub struct Example { pages: State, } impl Default for Example { fn default() -> Self { Self { pages: State::for_items(INVOICES.len(), PER_PAGE), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Page(event) = message; let _ = self.pages.update(event); } pub fn view(&self) -> Element<'_, Message> { let rows = INVOICES[self.pages.range(INVOICES.len(), PER_PAGE)] .iter() .map(|(number, amount)| { row![ text(*number).size(14), space::horizontal(), text(*amount).size(14), ] .height(36) .align_y(Alignment::Center) .into() }); column![ column(rows).height(Length::Fixed(4.0 * 36.0)), pagination(&self.pages) .variant(Variant::Compact) .size(Size::Sm) .on_event(Message::Page), ] .spacing(12) .align_x(Alignment::Center) .width(Length::Fill) .max_width(300) .into() } } ``` ### Keyboard shortcuts Page Up, Page Down, Ctrl+Home and Ctrl+End through the default keymap. ```rust use iced::widget::{column, text}; use iced::{Alignment, Element, Subscription}; use iced_cube::keys::{self, Keymap}; use iced_cube::navigation::pagination::{self, Action, Event, State, pagination}; #[derive(Debug, Clone)] pub enum Message { Page(Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { pages: State, keymap: Keymap, } impl Default for Example { fn default() -> Self { Self { pages: State::new(12).with_page(4), keymap: pagination::default_keymap(), } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Page(event) => Some(event), Message::Key(key) => self .keymap .resolve_event(&key) .and_then(|action| action.event(&self.pages)), }; if let Some(event) = event { let _ = self.pages.update(event); } } pub fn view(&self) -> Element<'_, Message> { column![ pagination(&self.pages).on_event(Message::Page), text("Page Up and Page Down step a page.").size(14), text("Ctrl+Home and Ctrl+End jump.").size(14), ] .spacing(8) .align_x(Alignment::Center) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `State::new(pages) / State::for_items(items, per_page)`: Starts on page 1. Pages count from 1 and there is always at least one. - `.with_page(page)`: Starts on a given page, kept within range. - `state.update(Event)`: Select(page), Next, Previous, First or Last. Returns the new page when it changed. - `state.page() / state.pages()`: The current page and the page count. - `state.range(items, per_page)`: The indices of the items on the current page, for slicing your list. - `state.set_pages(pages)`: Changes the page count, such as after a filter, and moves the current page back into range. - `pagination(&state)`: Renders the controls. They render disabled until .on_event is set. - `.variant(Variant)`: Numbers, page numbers between previous and next buttons, or Compact, "Page 3 of 12" between first, previous, next and last buttons. Defaults to Numbers. - `.siblings(n)`: How many pages to show on each side of the current one. Defaults to 1. - `.size(Size)`: The button Size: Sm, Md or Lg. Defaults to Md. - `.on_event(f)`: Maps pagination events to your message, such as Message::Page. - `pagination::slots(page, pages, siblings)`: The page numbers and gaps the numbered layout shows, for custom layouts. - `pagination::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.event(&state)`: Turns a resolved Action (Next, Previous, First or Last) into the Event to pass to state.update, or None at that end already. ## Keyboard - PageDown: `Next`. Goes to the next page. - PageUp: `Previous`. Goes to the previous page. - Ctrl+Home: `First`. Goes to the first page. - Ctrl+End: `Last`. Goes to the last page. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/breadcrumb.md - https://sam-ruff.github.io/iced-cube/docs/components/tabs.md --- # Tooltip > A short label that appears while the pointer rests on an element. ```rust use iced_cube::tooltip; use iced_cube::overlay::tooltip::Position; ``` ## Usage Use a tooltip to name an icon-only button or to add a short hint. Keep the label to a few words, and never put anything in it that people need to finish a task, because touch screens have no hover. The bubble uses the primary colours, so it reads as dark on a light page and light on a dark one. It moves to stay inside the window when there is no room on the requested side. ## Examples ### Icon toolbar Labels for icon-only buttons. ```rust use iced::widget::{column, row, text}; use iced::{Alignment, Element}; use iced_cube::overlay::tooltip::tooltip; use iced_cube::{Glyph, icon_button, lucide}; #[derive(Debug, Clone)] pub enum Message { Format(&'static str), } #[derive(Debug, Default)] pub struct Example { last: Option<&'static str>, } impl Example { pub fn update(&mut self, message: Message) { let Message::Format(name) = message; self.last = Some(name); } pub fn view(&self) -> Element<'_, Message> { let tool = |glyph: Glyph, name: &'static str| { tooltip(icon_button(glyph).on_press(Message::Format(name)), name) }; let status = match self.last { Some(name) => format!("Applied: {name}"), None => String::from("Hover an icon to see what it does."), }; column![ row![ tool(lucide!(Bold), "Bold"), tool(lucide!(Italic), "Italic"), tool(lucide!(Underline), "Underline"), tool(lucide!(Link), "Insert link"), ] .spacing(4), text(status).size(14), ] .spacing(12) .align_x(Alignment::Center) .into() } } ``` ### Positions The bubble on each side of its element. ```rust use iced::Element; use iced::widget::row; use iced_cube::button; use iced_cube::overlay::tooltip::{Position, tooltip}; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone)] pub enum Message { Pressed, } #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let sides = [ Position::Top, Position::Bottom, Position::Left, Position::Right, ]; row(sides.map(|position| { tooltip( button(format!("{position:?}")) .variant(Variant::Outline) .on_press(Message::Pressed), format!("Shown on the {position:?}").to_lowercase(), ) .position(position) .into() })) .spacing(8) .into() } } ``` ## API - `tooltip(content, label)`: Shows the label in a bubble while the pointer is over the content. - `.position(Position)`: Top, Bottom, Left, Right or FollowCursor. Defaults to Top. - `.delay(duration)`: How long the pointer has to rest on the element before the bubble appears. Defaults to zero, so it appears straight away. - `style(tokens)`: The bubble style, for reuse in custom overlays. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/button.md --- # Toast > Short notifications that stack in a corner and close by themselves. ```rust use iced_cube::overlay::toast::{self, Position, Variant, toast, toasts}; ``` ## Usage Toasts often come from work running off the UI thread, such as a download or a sync. They reach the app through a bounded channel rather than shared state: - `toast::subscription()` creates the channel and owns the receiver. Its first event is `Ready` with the sender. - The app keeps the sender and gives a clone to each producer. - The subscription delivers whatever is waiting in the channel in batches of up to 16, so a burst causes a handful of redraws rather than one per toast. - `toast::timer` only runs while a toast is counting down. A whole app that raises a toast from a worker thread: ```rust use iced::{Element, Subscription}; use iced_cube::button; use iced_cube::overlay::toast::{self, toast, toasts}; #[derive(Debug, Clone)] enum Message { Toast(toast::Event), Export, } #[derive(Default)] struct App { toasts: toast::State, sender: Option, } impl App { fn update(&mut self, message: Message) { match message { Message::Toast(event) => { if let Some(toast::Output::Ready(sender)) = self.toasts.update(event) { self.sender = Some(sender); } } Message::Export => { let Some(mut sender) = self.sender.clone() else { return; }; std::thread::spawn(move || { // Do the work, then report back. try_send drops the // toast if the channel is full. let _ = sender.try_send(toast("Export finished")); }); } } } fn view(&self) -> Element<'_, Message> { toasts(&self.toasts, button("Export").on_press(Message::Export)) .on_event(Message::Toast) .into() } fn subscription(&self) -> Subscription { Subscription::batch([toast::subscription(), toast::timer(&self.toasts)]) .map(Message::Toast) } } fn main() -> iced::Result { iced::application(App::default, App::update, App::view) .subscription(App::subscription) .run() } ``` [Subscriptions](https://sam-ruff.github.io/iced-cube/docs/subscriptions.md) covers sending from async code, waiting for room instead of dropping, and how the timer stays idle. An Undo button needs to know what to undo. Rather than keeping toast ids and matching them, queue the toast with `state.push_with(toast, message)` on a `toast::State`. When the button is pressed, `update` returns `Output::Payload(message)`, and you handle it like any other message, as the action example does. If the toast is dismissed or times out, the message is dropped with it. Payloads only go through `push_with` on the UI thread; toasts from the channel have none. Undo is easy to miss, so a toast with an action gets more room than the rest. It stays at least ten seconds, it goes ahead of any toasts waiting their turn (taking the place of the newest plain toast if all three slots are full), and `Alt+Z` presses its button through the keymap. Every countdown pauses while the pointer is over the toasts, so nothing closes as you reach for it. Time only enters the queue through `Tick`, so the queue is easy to test: push toasts, call `state.tick(now)` with any instant, and check what is left. A toast starts counting down when it becomes visible, not when it arrives. The background job example uses an async stream that waits with `futures-timer` instead of a thread, so it also runs in the browser. ## Examples ### Variants Default, success and destructive toasts. ```rust use iced::widget::row; use iced::{Element, Subscription}; use iced_cube::button; use iced_cube::overlay::toast::{self, Variant, toast, toasts}; use iced_cube::primitives::button::Variant as ButtonVariant; #[derive(Debug, Clone)] pub enum Message { Show(Variant), Toast(toast::Event), } #[derive(Debug)] pub struct Example { toasts: toast::State, } impl Default for Example { fn default() -> Self { let mut toasts = toast::State::new(); toasts.push(toast("Event created").description("Sunday, 4 October at 9:00")); Self { toasts } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Show(variant) => { let toast = match variant { Variant::Default => { toast("Event created").description("Sunday, 4 October at 9:00") } Variant::Success => { toast("Profile saved").description("Your changes are live.") } Variant::Destructive => { toast("Payment declined").description("Check your card details.") } }; self.toasts.push(toast.variant(variant)); } Message::Toast(event) => { let _ = self.toasts.update(event); } } } pub fn view(&self) -> Element<'_, Message> { let buttons = row(Variant::ALL.map(|variant| { button(format!("{variant:?}")) .variant(ButtonVariant::Outline) .on_press(Message::Show(variant)) .into() })) .spacing(8) .wrap(); toasts(&self.toasts, buttons) .on_event(Message::Toast) .into() } pub fn subscription(&self) -> Subscription { toast::timer(&self.toasts).map(Message::Toast) } } ``` ### Background job A producer sends toasts through the channel. It waits with futures-timer rather than a thread, so it also runs in the browser. ```rust use std::hash::{Hash, Hasher}; use std::time::Duration; use futures_timer::Delay; use iced::futures::{SinkExt, Stream, stream}; use iced::widget::{column, text}; use iced::{Element, Subscription}; use iced_cube::button; use iced_cube::overlay::toast::{self, Variant, toast, toasts}; #[derive(Debug, Clone)] pub enum Message { Toast(toast::Event), StartJob, JobFinished, } #[derive(Debug)] pub struct Example { toasts: toast::State, sender: Option, job: Option, runs: u64, } impl Default for Example { fn default() -> Self { let mut toasts = toast::State::new(); toasts.push( toast("Build finished") .description("All 214 tests passed.") .variant(Variant::Success), ); toasts.push( toast("Upload failed") .description("The server did not respond.") .variant(Variant::Destructive), ); Self { toasts, sender: None, job: None, runs: 0, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Toast(event) => { if let Some(toast::Output::Ready(sender)) = self.toasts.update(event) { self.sender = Some(sender); } } Message::StartJob => { let Some(sender) = self.sender.clone() else { return; }; self.runs += 1; self.job = Some(Job { run: self.runs, sender, }); } Message::JobFinished => self.job = None, } } pub fn view(&self) -> Element<'_, Message> { let status = if self.job.is_some() { "The job is sending progress through the channel." } else { "Producers send toasts through a channel." }; let start = self.job.is_none().then_some(Message::StartJob); let content = column![ text(status).size(14), button("Start background job").on_press_maybe(start), ] .spacing(12); toasts(&self.toasts, content) .on_event(Message::Toast) .into() } pub fn subscription(&self) -> Subscription { let job = match &self.job { Some(job) => Subscription::run_with(job.clone(), Job::run), None => Subscription::none(), }; Subscription::batch([ toast::subscription().map(Message::Toast), toast::timer(&self.toasts).map(Message::Toast), job, ]) } } /// Simulated work that only holds a sender, like a worker thread would. #[derive(Debug, Clone)] struct Job { run: u64, sender: toast::Sender, } impl Hash for Job { fn hash(&self, state: &mut H) { self.run.hash(state); } } impl Job { fn run(&self) -> impl Stream + use<> { let mut sender = self.sender.clone(); stream::once(async move { for step in 1..=3 { Delay::new(Duration::from_millis(900)).await; let _ = sender .send(toast(format!("Step {step} of 3 complete"))) .await; } let _ = sender .send(toast("Job finished").variant(Variant::Success)) .await; Message::JobFinished }) } } ``` ### With action An undo action in the top right corner that hands back the message which reverses the archive. It stays at least ten seconds, pauses under the pointer, and Alt+Z presses it. ```rust use iced::widget::{column, text}; use iced::{Element, Subscription}; use iced_cube::keys::{self, Keymap}; use iced_cube::overlay::toast::{self, Action, Position, toast, toasts}; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; #[derive(Debug, Clone)] pub enum Message { Archive, Unarchive, Toast(toast::Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { inbox: u32, // Undo buttons hand back the message that reverses the archive. toasts: toast::State, keymap: Keymap, } impl Default for Example { fn default() -> Self { let mut toasts = toast::State::default(); toasts.push_with(archived().persistent(), Message::Unarchive); Self { inbox: 11, toasts, keymap: toast::default_keymap(), } } } // A toast with an action stays at least ten seconds, and pauses while the // pointer is over it. fn archived() -> toast::Toast { toast("Conversation archived").action("Undo") } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Archive => { self.inbox -= 1; self.toasts.push_with(archived(), Message::Unarchive); } Message::Unarchive => self.inbox += 1, Message::Toast(event) => { if let Some(toast::Output::Payload(message)) = self.toasts.update(event) { self.update(message); } } // Alt+Z presses Undo on the newest toast. Message::Key(key) => { let Some(action) = self.keymap.resolve_event(&key) else { return; }; for event in action.events(&self.toasts) { self.update(Message::Toast(event)); } } } } pub fn view(&self) -> Element<'_, Message> { let archive = (self.inbox > 0).then_some(Message::Archive); let content = column![ text(format!("{} conversations in your inbox", self.inbox)).size(14), button("Archive") .icon(lucide!(Archive)) .variant(Variant::Secondary) .on_press_maybe(archive), text("Alt+Z undoes the newest archive.").size(14), ] .spacing(12); toasts(&self.toasts, content) .position(Position::TopRight) .on_event(Message::Toast) .into() } pub fn subscription(&self) -> Subscription { Subscription::batch([ toast::timer(&self.toasts).map(Message::Toast), keys::subscription().map(Message::Key), ]) } } ``` ### Keyboard shortcuts Escape closes the newest toast and Shift+Escape closes them all, through the default keymap. ```rust use iced::widget::{column, text}; use iced::{Element, Subscription}; use iced_cube::button; use iced_cube::keys::{self, Keymap}; use iced_cube::overlay::toast::{self, Action, toast, toasts}; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone)] pub enum Message { Notify, Toast(toast::Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { toasts: toast::State, keymap: Keymap, sent: u32, } impl Default for Example { fn default() -> Self { let mut toasts = toast::State::new(); toasts.push(toast("Press Escape to close me").persistent()); Self { toasts, keymap: toast::default_keymap(), sent: 0, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Notify => { self.sent += 1; self.toasts .push(toast(format!("Message {} sent", self.sent)).persistent()); } Message::Toast(event) => { let _ = self.toasts.update(event); } Message::Key(key) => { let Some(action) = self.keymap.resolve_event(&key) else { return; }; for event in action.events(&self.toasts) { let _ = self.toasts.update(event); } } } } pub fn view(&self) -> Element<'_, Message> { let content = column![ button("Send message") .variant(Variant::Outline) .on_press(Message::Notify), text("Escape closes the newest toast, Shift+Escape closes them all.").size(14), ] .spacing(12); toasts(&self.toasts, content) .on_event(Message::Toast) .into() } pub fn subscription(&self) -> Subscription { Subscription::batch([ toast::timer(&self.toasts).map(Message::Toast), keys::subscription().map(Message::Key), ]) } } ``` ## API - `toast(title)`: Creates a toast that closes five seconds after it becomes visible. - `.description(text) / .variant(Variant)`: Adds a second line. Variants are Default, Success and Destructive. - `.action(label)`: Adds a button. Pressing it closes the toast and returns Output::Action. A toast with an action stays at least ACTION_DURATION (10 seconds) and shows ahead of toasts waiting their turn. - `.duration(duration) / .persistent()`: Changes how long the toast stays, or keeps it until it is closed. - `toast::State`: The queue. At most three toasts show at once by default; change it with .with_limit(n). State::new() makes a queue without payloads; State::::default() makes one whose action buttons can hand back your messages. - `state.update(Event)`: Handles Ready, Received, Dismiss, Action, Tick, Pause and Resume. Returns Output::Ready(sender), Output::Payload(value) for a toast queued with push_with, or Output::Action(id, toast) for any other action. - `state.push(toast)`: Queues a toast from the UI thread without the channel, and returns its Id. - `state.push_with(toast, payload)`: Queues a toast whose action button hands back payload, such as the message that undoes what it reports. Dismissing or expiring drops the payload. - `state.dismiss(id)`: Removes a toast, visible or waiting, and returns it. - `state.tick(now)`: Closes toasts whose time is up at now and starts the countdown of newly visible ones. While paused it adds the time since the last tick back to every countdown. Returns the ids that closed. - `state.is_paused()`: Whether the pointer is over the toasts, which stops every countdown until it leaves. - `toast.lifetime()`: How long the toast stays once shown, after the action minimum is applied, or None for a persistent toast. - `state.visible() / state.waiting() / state.len()`: The visible toasts with their ids, how many are queued behind them, and the total. - `toast::subscription()`: Owns the channel. Emits Ready(sender) once, then batches of up to BATCH_SIZE (16) toasts. - `toast::Sender`: The sending half of the channel, which holds CHANNEL_CAPACITY (32) toasts. Clone it for each producer. - `toast::timer(&state)`: Ticks every TICK (250 ms) while a visible toast is counting down, and is idle otherwise. - `toasts(&state, content)`: Renders the toasts over your content. Toast buttons render disabled until .on_event(f) is set. - `.position(Position)`: TopLeft, TopCenter, TopRight, BottomLeft, BottomCenter or BottomRight. Defaults to BottomRight. - `toast::close_button_id(id)`: The widget id of a toast's close button, for focusing it or clicking it in tests. - `toast::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.events(&state)`: The events for a resolved Action: Dismiss for DismissLatest or DismissAll, and Action for ActivateLatest, which presses the button of the newest visible toast that has one. Pass each one to state.update. - `state.ids()`: Every queued toast id, visible first. ## Keyboard - Escape: `DismissLatest`. Closes the newest visible toast. - Shift+Escape: `DismissAll`. Closes every toast, including those waiting their turn. - Alt+Z: `ActivateLatest`. Presses the action button, such as Undo, of the newest visible toast that has one. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/alert.md --- # Dialog > A modal window over a dimmed scrim, for a focused task or a confirmation. ```rust use iced_cube::{alert_dialog, dialog}; use iced_cube::overlay::dialog::{self, Size}; ``` ## Usage Wrap the root of your view in `dialog`, so the scrim covers the whole window. While the dialog is open, the content underneath still draws but gets no clicks, hover or key presses. It keeps its widget state, such as scroll positions, when the dialog opens and closes. Use a dialog for a short task that needs attention before people go back to what they were doing. For a destructive confirmation, use `alert_dialog`: it has no close button and ignores clicks on the scrim, so the only ways out are Cancel, Escape or the destructive action. Enter does nothing in it by default, because a key press meant for something else should never delete anything; turn it on with `.confirm_on_enter(true)` when the action is easy to undo. Turn Escape off with `.dismiss_on_escape(false)` when even that is too easy. Keyboard users answer an alert dialog with Tab: nothing is focused when it opens, the first Tab rings Cancel, the next rings the destructive action, and Enter or Space presses the ringed button. Shift+Tab goes back, and a click anywhere clears the ring. To confirm with a single chord instead, bind one to `Action::Confirm` in the keymap and turn on `.confirm_on_enter(true)`. The dialog handles its own keys, so it needs no subscription. When it opens, it focuses its first text field and unfocuses anything underneath. Tab and Shift+Tab then move between the text fields inside it and wrap at either end, so focus never leaves the dialog. It captures every key press its content leaves, so app-wide shortcuts from `keys::subscription` cannot change tabs, sliders or menus behind the scrim. Let a chosen shortcut through with `.pass_through(...)`. Menus, comboboxes and command lists inside a dialog get keys and clicks first. Escape or a click on the scrim closes an open menu or list and leaves the dialog open; the next one closes the dialog. The [command palette](https://sam-ruff.github.io/iced-cube/docs/components/command.md) example puts a command list in a dialog. Some limits come from iced 0.14 itself: - Only text fields, text editors and an alert dialog's own two buttons can take keyboard focus. Tab skips the buttons you pass to `.action(...)`, so Space cannot press them. Set `.on_confirm(...)` so Enter runs the main action, or give a field an `on_submit` of its own. - A focused text field takes the first Escape to lose focus, so it takes a second Escape to dismiss the dialog. - There is no screen reader support, so the dialog's title and description are not announced. ## Examples ### Default A trigger button opens the dialog. Escape, the close button or a click on the scrim dismisses it. ```rust use iced::widget::{container, row, text}; use iced::{Alignment, Element}; use iced_cube::button; use iced_cube::overlay::dialog::dialog; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone)] pub enum Message { Open, Close, Publish, } #[derive(Debug)] pub struct Example { open: bool, published: bool, } impl Default for Example { fn default() -> Self { Self { open: true, published: false, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Open => self.open = true, Message::Close => self.open = false, Message::Publish => { self.published = true; self.open = false; } } } pub fn view(&self) -> Element<'_, Message> { let status = if self.published { "The post is live." } else { "Draft saved two minutes ago." }; let base = row![ button("Publish post") .variant(Variant::Outline) .on_press(Message::Open), text(status).size(14), ] .spacing(12) .align_y(Alignment::Center); dialog(container(base).padding(24)) .open(self.open) .title("Publish this post?") .description("Subscribers get an email as soon as it goes live.") .action( button("Cancel") .variant(Variant::Outline) .on_press(Message::Close), ) .action(button("Publish").on_press(Message::Publish)) .on_dismiss(Message::Close) .into() } } ``` ### Form and keyboard Fields inside a small dialog. Tab and Shift+Tab move focus between them through the default keymap, without leaving the dialog, and Enter saves through on_confirm. ```rust use iced::widget::{column, container, row, text}; use iced::{Alignment, Element}; use iced_cube::overlay::dialog::{Size, dialog}; use iced_cube::primitives::button::Variant; use iced_cube::{button, field, input}; #[derive(Debug, Clone)] pub enum Message { Open, Close, NameChanged(String), UsernameChanged(String), Save, } #[derive(Debug)] pub struct Example { open: bool, name: String, username: String, saved: String, } impl Default for Example { fn default() -> Self { Self { open: true, name: String::from("Ada Lovelace"), username: String::from("ada"), saved: String::from("Ada Lovelace"), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Open => self.open = true, Message::Close => { self.name = self.saved.clone(); self.open = false; } Message::NameChanged(name) => self.name = name, Message::UsernameChanged(username) => self.username = username, Message::Save => { self.saved = self.name.clone(); self.open = false; } } } pub fn view(&self) -> Element<'_, Message> { let base = row![ button("Edit profile") .variant(Variant::Outline) .on_press(Message::Open), text(format!("Signed in as {}", self.saved)).size(14), ] .spacing(12) .align_y(Alignment::Center); let form = column![ field( "Name", input("Your name", &self.name).on_input(Message::NameChanged), ), field( "Username", input("username", &self.username).on_input(Message::UsernameChanged), ), ] .spacing(16); dialog(container(base).padding(24)) .open(self.open) .title("Edit profile") .description("Tab and Shift+Tab move between the fields. Enter saves, Escape cancels.") .body(form) .action(button("Save changes").on_press(Message::Save)) .on_confirm(Message::Save) .on_dismiss(Message::Close) .size(Size::Sm) .into() } } ``` ### Destructive confirmation An alert dialog with Cancel and a destructive action. Enter confirms, Escape cancels and clicking the scrim does nothing. ```rust use iced::widget::{container, row, text}; use iced::{Alignment, Element}; use iced_cube::button; use iced_cube::overlay::dialog::alert_dialog; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone)] pub enum Message { Ask, Cancel, Delete, } #[derive(Debug)] pub struct Example { asking: bool, deleted: bool, } impl Default for Example { fn default() -> Self { Self { asking: true, deleted: false, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Ask => self.asking = true, Message::Cancel => self.asking = false, Message::Delete => { self.deleted = true; self.asking = false; } } } pub fn view(&self) -> Element<'_, Message> { let status = if self.deleted { "The project was deleted." } else { "3 members, 128 files." }; let delete = (!self.deleted).then_some(Message::Ask); let base = row![ button("Delete project") .variant(Variant::Destructive) .on_press_maybe(delete), text(status).size(14), ] .spacing(12) .align_y(Alignment::Center); alert_dialog( container(base).padding(24), "Delete this project?", "This permanently deletes the project and its files. It cannot be undone.", ) .open(self.asking) .confirm("Delete project") .on_cancel(Message::Cancel) .on_confirm(Message::Delete) .into() } } ``` ## API - `dialog(base)`: Wraps the content the dialog opens over. The dialog stays closed until .open(true). - `.open(bool)`: Whether the dialog shows. Your app holds this value. - `.title(text) / .description(text)`: The heading, and a line of muted text under it. - `.body(element)`: Content between the header and the footer, such as a form. - `.action(element)`: Adds a button to the footer, which lines them up on the right. Add the main action last. - `.on_dismiss(message)`: Emitted by Escape, a click on the scrim and the close button. Without it the close button renders disabled. - `.on_confirm(message)`: Emitted by Enter, through the keymap's Confirm action, usually the same message as the main footer action. Without it Enter does nothing. A field inside that submits on Enter keeps the key. - `.dismiss_on_escape(bool) / .dismiss_on_scrim(bool)`: Turns either way of dismissing off. Both default to true. - `.close_button(bool)`: Shows or hides the close button in the top right corner. Defaults to true. - `.size(Size) / .width(pixels)`: The maximum width: Sm (384), Md (512, the default) or Lg (640). The dialog shrinks to fit smaller windows. - `.keymap(Keymap)`: Replaces the default shortcuts. - `.pass_through(chords)`: Chords the open dialog lets through to keys::subscription, such as the shortcut that toggles a command palette. Every other key press stops at the dialog. - `.id(id)`: The surface's widget id, which scopes focus. Give nested dialogs different ids. - `alert_dialog(base, title, description)`: A confirmation with Cancel and a destructive button. The scrim does not dismiss it and it has no close button. - `.cancel(label) / .confirm(label)`: The button labels. They default to Cancel and Continue. - `.on_cancel(message) / .on_confirm(message)`: Emitted by the buttons. Escape also cancels unless .dismiss_on_escape(false) is set. - `.confirm_on_enter(bool)`: Whether Enter runs the destructive action while no button is focused. Defaults to false, so a stray Enter never destroys anything; keyboard users Tab to the button and press Enter or Space instead. - `dialog::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. - `action.effect(dismiss_on_escape)`: What a resolved Action does to an open dialog: Effect::Dismiss, Effect::Confirm or Effect::Focus(direction). - `dialog::CLOSE_BUTTON_ID`: The widget id of the close button, for clicking it in tests. - `surface_style(tokens) / scrim_style(tokens)`: The surface and scrim styles, for reuse in custom overlays. ## Keyboard - Escape: `Close`. Dismisses the dialog, unless Escape dismissal is turned off. - Enter: `Confirm`. Sends the dialog's confirm message. An alert dialog's destructive action only runs on Enter when it opts in. - Tab: `FocusNext`. Focuses the next text field, or alert dialog button, wrapping from the last to the first. - Shift+Tab: `FocusPrevious`. Focuses the previous text field, or alert dialog button, wrapping from the first to the last. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/button.md - https://sam-ruff.github.io/iced-cube/docs/components/icon-button.md - https://sam-ruff.github.io/iced-cube/docs/components/toast.md --- # Popover > A floating panel of any content, anchored to the button that opens it. ```rust use iced_cube::popover; use iced_cube::overlay::popover::{Align, Side}; ``` ## Usage A popover holds a small piece of UI that belongs to one button, such as a settings form, a colour picker or a short explanation. It is not modal: the rest of the window stays usable, and clicking elsewhere closes it through `on_dismiss` while the click still reaches what it landed on. Menus, comboboxes and command lists inside a popover close first: Escape or a click outside closes the inner layer and leaves the popover open. For choosing one action from a list, use a [dropdown menu](https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md) instead, and for a label that appears on hover, a [tooltip](https://sam-ruff.github.io/iced-cube/docs/components/tooltip.md). While a text field in the panel has focus, Tab and Shift+Tab move between the panel's fields and wrap at either end, so a form in a popover works from the keyboard. Tab anywhere else is left alone. Your app owns whether the popover is open. Toggle a flag from the trigger's message, clear it on dismiss and pass it to `.open`. Clicks on the trigger are left to the trigger, so it can close the popover as well as open it. The panel moves to the opposite side when there is no room on the one you asked for, and slides along its side to stay inside the window. A panel on the left or right that fits on neither moves below or above the trigger instead of covering it. Clicks inside it never reach the widgets underneath. ## Examples ### Settings form A small form in a popover that closes on Escape or a click outside. ```rust use iced::widget::{column, container, row, text}; use iced::{Alignment, Element}; use iced_cube::overlay::popover::popover; use iced_cube::primitives::{button::Variant, input::Size}; use iced_cube::theme::{Tokens, semibold, text_size}; use iced_cube::{button, input, label}; #[derive(Debug, Clone)] pub enum Message { Toggle, Dismiss, Width(String), Height(String), MaxWidth(String), MaxHeight(String), } #[derive(Debug)] pub struct Example { open: bool, width: String, height: String, max_width: String, max_height: String, } impl Default for Example { fn default() -> Self { Self { open: true, width: "100%".into(), height: "25px".into(), max_width: "300px".into(), max_height: "none".into(), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Toggle => self.open = !self.open, Message::Dismiss => self.open = false, Message::Width(value) => self.width = value, Message::Height(value) => self.height = value, Message::MaxWidth(value) => self.max_width = value, Message::MaxHeight(value) => self.max_height = value, } } pub fn view(&self) -> Element<'_, Message> { let setting = |name, value: &str, on_input: fn(String) -> Message| { row![ container(label(name)).width(96), input("", value).size(Size::Sm).on_input(on_input), ] .spacing(8) .align_y(Alignment::Center) }; let form = column![ text("Dimensions").size(text_size::MD).font(semibold()), text("Set the dimensions for the layer.") .size(text_size::SM) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), column![ setting("Width", &self.width, Message::Width), setting("Max. width", &self.max_width, Message::MaxWidth), setting("Height", &self.height, Message::Height), setting("Max. height", &self.max_height, Message::MaxHeight), ] .spacing(8), ] .spacing(8); let trigger = button("Open popover") .variant(Variant::Outline) .on_press(Message::Toggle); container( popover(trigger, form) .open(self.open) .on_dismiss(Message::Dismiss), ) .height(300) .into() } } ``` ### Side and alignment A popover on each side of its trigger, aligned to the start, centre or end. ```rust use iced::widget::{column, row, text}; use iced::{Element, Length}; use iced_cube::button; use iced_cube::overlay::popover::{Align, Side, popover}; use iced_cube::primitives::button::{Size, Variant}; use iced_cube::theme::text_size; #[derive(Debug, Clone)] pub enum Message { Toggle(Side), Align(Align), Dismiss, } #[derive(Debug)] pub struct Example { open: Option, last: Side, align: Align, } impl Default for Example { fn default() -> Self { Self { open: Some(Side::Bottom), last: Side::Bottom, align: Align::Start, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Toggle(side) => { self.open = (self.open != Some(side)).then_some(side); self.last = side; } // A click on an alignment also dismisses the popover, so reopen it. Message::Align(align) => { self.align = align; self.open = Some(self.last); } Message::Dismiss => self.open = None, } } pub fn view(&self) -> Element<'_, Message> { let name = |align| match align { Align::Start => "Start", Align::Center => "Centre", Align::End => "End", }; let alignments = row(Align::ALL.map(|align| { let variant = if align == self.align { Variant::Secondary } else { Variant::Ghost }; button(name(align)) .size(Size::Sm) .variant(variant) .on_press(Message::Align(align)) .into() })) .spacing(4); let sides = row(Side::ALL.map(|side| { let label = text(format!( "{side:?}, aligned to {}", name(self.align).to_lowercase() )) .size(text_size::SM); popover( button(format!("{side:?}")) .variant(Variant::Outline) .on_press(Message::Toggle(side)), label, ) .open(self.open == Some(side)) .side(side) .align(self.align) .width(Length::Shrink) .padding(12.0) .on_dismiss(Message::Dismiss) .into() })) .spacing(8); column![alignments, sides].spacing(16).into() } } ``` ### Keyboard shortcuts Escape closes the popover, and an added binding toggles it with I. ```rust use iced::widget::{column, container, text}; use iced::{Element, Subscription}; use iced_cube::button; use iced_cube::keys::{self, Chord, Keymap}; use iced_cube::overlay::popover::{self, Action, popover}; use iced_cube::primitives::button::Variant; use iced_cube::theme::{Tokens, semibold, text_size}; #[derive(Debug, Clone)] pub enum Message { Toggle, Dismiss, Key(keys::Event), } #[derive(Debug)] pub struct Example { open: bool, keymap: Keymap, } impl Default for Example { fn default() -> Self { Self { open: true, keymap: popover::default_keymap().bind(Chord::character('i'), Action::Toggle), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Toggle => self.open = !self.open, Message::Dismiss => self.open = false, Message::Key(key) => { let next = self .keymap .resolve_event(&key) .and_then(|action| action.apply(self.open)); if let Some(open) = next { self.open = open; } } } } pub fn view(&self) -> Element<'_, Message> { let info = column![ text("Release notes").size(text_size::SM).font(semibold()), text("Version 0.0.2 adds popovers and menus.") .size(text_size::SM) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .spacing(4); let trigger = button("What's new") .variant(Variant::Outline) .on_press(Message::Toggle); column![ text("Press I to open or close the popover, or Escape to close it.").size(14), container( popover(trigger, info) .open(self.open) .width(240) .keymap(self.keymap.clone()) .on_dismiss(Message::Dismiss), ) .height(140), ] .spacing(16) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `popover(trigger, content)`: Shows the content in a panel next to the trigger while it is open. The trigger opens it itself, usually a button that sets a flag in your state. - `.open(bool)`: Whether the panel is showing. Defaults to false. - `.on_dismiss(message)`: Sent on Escape and on a press outside the panel and the trigger. Without it, only your app closes the popover. - `.side(Side) / .align(Align)`: Top, Bottom, Left or Right, and Start, Center or End. Defaults to below the trigger, centred. - `.gap(f32)`: Space between the trigger and the panel. Defaults to 4. - `.width(length) / .padding(f32)`: The panel is 288 wide with 16 of padding by default. Length::Shrink fits the content. - `.keymap(keymap)`: Replaces the default keymap. The open popover closes on the chords bound to Action::Close, after its content has had the key. - `style(tokens)`: The panel style, the floating surface shared with menus and dialogs. - `popover::default_keymap()`: The default shortcuts as a Keymap. Bind a chord to Action::Toggle to give a popover an app-wide shortcut. - `action.apply(open)`: Whether the popover should be open after a resolved Action (Toggle or Close), or None when nothing changes. ## Keyboard - Escape: `Close`. Closes the popover. - Tab: `FocusNext`. Moves from a field in the popover to its next field, wrapping at the end. - Shift+Tab: `FocusPrevious`. Moves from a field in the popover to its previous field, wrapping at the start. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/tooltip.md --- # Dropdown menu > A menu of actions that opens from a button, with check marks, radio choices and submenus. ```rust use iced_cube::dropdown_menu::{ self, Event, Output, State, checkbox_item, dropdown_menu, group_label, item, radio_item, separator, submenu, }; ``` ## Usage The app owns a `dropdown_menu::State` and routes its events into it. When `update` returns an `Output`, the menu has already closed and updated its own check marks, so the app only acts on the choice. Read checkbox and radio values back with `state.is_checked(id)`, or keep your own copy and call `set_checked` when it changes elsewhere. The trigger is any element you pass in, usually a button whose message carries `Event::Toggle`. Escape and a click outside send `Event::Close` through `on_event`, and Escape inside a submenu closes just that submenu. The click that closes the menu goes no further, so a menu inside a [dialog](https://sam-ruff.github.io/iced-cube/docs/components/dialog.md) closes without closing the dialog too. Clicks inside the menu never reach the widgets underneath it, and a tooltip on the trigger stays hidden while the menu is open. Moving the pointer over an item highlights it, and the keyboard moves the same highlight. The open menu handles its own keys before anything else sees them, so it needs no subscription. A closed menu claims no keys at all, so the arrow keys, Enter and Space stay free for the rest of your app. To open a menu from the keyboard, bind a chord to `Action::Open` and route presses from `keys::subscription()` through `state.key_event`, as the keyboard example does. The [keyboard guide](https://sam-ruff.github.io/iced-cube/docs/keyboard.md) explains how keys reach each layer. The menu opens on the side you ask for, or the opposite side when only that one has room for all of it. When neither does, it takes the roomier side and scrolls, so it never covers its trigger. A submenu opens beside its item, flipping to the other side near the edge of the window. On a screen too narrow for either, it drops below or above the item instead, so the item's label stays visible. When any item in a menu has an icon, a check mark or a radio dot, every row gets one leading slot, so all the labels start at the same place. A checked item shows its mark in that slot, and any other item shows its icon there, or leaves it empty. Shortcut hints are only labels. Bind the keys themselves in your own keymap. The [context menu](https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md) uses the same entries and rows, opened with a right-click. ## Examples ### Default Items with icons and shortcut hints, a submenu, a disabled item and a destructive one. ```rust use iced::widget::{column, container, text}; use iced::{Element, Length}; use iced_cube::dropdown_menu::{ Event, Output, State, dropdown_menu, group_label, item, separator, submenu, }; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Id { Profile, Billing, Settings, Invite, Email, Chat, NewTeam, Support, Api, Delete, } #[derive(Debug, Clone)] pub enum Message { Menu(Event), } #[derive(Debug)] pub struct Example { menu: State, chosen: Option, } impl Default for Example { fn default() -> Self { let mut menu = State::new([ group_label("My account"), item(Id::Profile, "Profile") .icon(lucide!(User)) .shortcut("Shift+Ctrl+P"), item(Id::Billing, "Billing") .icon(lucide!(CreditCard)) .shortcut("Ctrl+B"), item(Id::Settings, "Settings") .icon(lucide!(Settings)) .shortcut("Ctrl+S"), separator(), submenu( Id::Invite, "Invite users", [ item(Id::Email, "Email").icon(lucide!(Mail)), item(Id::Chat, "Message").icon(lucide!(MessageSquare)), ], ) .icon(lucide!(UserPlus)), item(Id::NewTeam, "New team") .icon(lucide!(Plus)) .shortcut("Ctrl+T"), separator(), item(Id::Support, "Support").icon(lucide!(LifeBuoy)), item(Id::Api, "API").icon(lucide!(Cloud)).disabled(true), separator(), item(Id::Delete, "Delete account") .icon(lucide!(Trash)) .destructive(true), ]); let _ = menu.update(Event::Open); Self { menu, chosen: None } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Menu(event) = message; if let Some(Output::Activated(id)) = self.menu.update(event) { self.chosen = Some(id); } } pub fn view(&self) -> Element<'_, Message> { let trigger = button("Open menu") .variant(Variant::Outline) .on_press(Message::Menu(Event::Toggle)); let chosen = match self.chosen { Some(id) => format!("Chose {id:?}"), None => "Nothing chosen yet".into(), }; column![ text(chosen).size(14), container(dropdown_menu(&self.menu, trigger).on_event(Message::Menu)) .height(Length::Fill), ] .spacing(12) .width(240) .into() } } ``` ### Checkbox items Items that toggle a check mark, one of them disabled. ```rust use iced::widget::{column, container, text}; use iced::{Element, Length}; use iced_cube::button; use iced_cube::dropdown_menu::{ Event, State, checkbox_item, dropdown_menu, group_label, separator, }; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Panel { StatusBar, ActivityBar, Minimap, } #[derive(Debug, Clone)] pub enum Message { Menu(Event), } #[derive(Debug)] pub struct Example { menu: State, } impl Default for Example { fn default() -> Self { let mut menu = State::new([ group_label("Appearance"), separator(), checkbox_item(Panel::StatusBar, "Status bar", true), checkbox_item(Panel::ActivityBar, "Activity bar", false).disabled(true), checkbox_item(Panel::Minimap, "Minimap", false), ]); let _ = menu.update(Event::Open); Self { menu } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Menu(event) = message; let _ = self.menu.update(event); } pub fn view(&self) -> Element<'_, Message> { let shown: Vec<&str> = [Panel::StatusBar, Panel::ActivityBar, Panel::Minimap] .into_iter() .filter(|&panel| self.menu.is_checked(panel)) .filter_map(|panel| self.menu.item(panel)) .map(|item| item.label.as_str()) .collect(); let summary = match shown.as_slice() { [] => "Showing nothing".to_string(), shown => format!("Showing: {}", shown.join(", ")), }; let trigger = button("View") .variant(Variant::Outline) .on_press(Message::Menu(Event::Toggle)); column![ text(summary).size(14), container(dropdown_menu(&self.menu, trigger).on_event(Message::Menu)) .height(Length::Fill), ] .spacing(12) .width(240) .into() } } ``` ### Radio items A group of items where choosing one unchecks the others. ```rust use iced::widget::{column, container, text}; use iced::{Element, Length}; use iced_cube::button; use iced_cube::dropdown_menu::{ Event, Output, State, dropdown_menu, group_label, radio_item, separator, }; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Position { Top, Bottom, Right, } #[derive(Debug, Clone)] pub enum Message { Menu(Event), } #[derive(Debug)] pub struct Example { menu: State, position: Position, } impl Default for Example { fn default() -> Self { let mut menu = State::new([ group_label("Panel position"), separator(), radio_item(Position::Top, "Top", false), radio_item(Position::Bottom, "Bottom", true), radio_item(Position::Right, "Right", false), ]); let _ = menu.update(Event::Open); Self { menu, position: Position::Bottom, } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Menu(event) = message; if let Some(Output::Selected(position)) = self.menu.update(event) { self.position = position; } } pub fn view(&self) -> Element<'_, Message> { let trigger = button("Panel") .variant(Variant::Outline) .on_press(Message::Menu(Event::Toggle)); column![ text(format!("Panel position: {:?}", self.position)).size(14), container(dropdown_menu(&self.menu, trigger).on_event(Message::Menu)) .height(Length::Fill), ] .spacing(12) .width(240) .into() } } ``` ### Submenu A nested menu that opens beside its item, on hover or with the right arrow key. ```rust use iced::widget::container; use iced::{Element, Length}; use iced_cube::button; use iced_cube::dropdown_menu::{Event, State, dropdown_menu, item, separator, submenu}; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Id { NewTab, NewWindow, Private, MoreTools, SavePage, Shortcut, NameWindow, DevTools, Print, } #[derive(Debug, Clone)] pub enum Message { Menu(Event), } #[derive(Debug)] pub struct Example { menu: State, } impl Default for Example { fn default() -> Self { let mut menu = State::new([ item(Id::NewTab, "New tab").shortcut("Ctrl+T"), item(Id::NewWindow, "New window").shortcut("Ctrl+N"), item(Id::Private, "Private window") .shortcut("Shift+Ctrl+N") .disabled(true), submenu( Id::MoreTools, "More tools", [ item(Id::SavePage, "Save page as").shortcut("Ctrl+S"), item(Id::Shortcut, "Create shortcut"), item(Id::NameWindow, "Name window"), separator(), item(Id::DevTools, "Developer tools"), ], ), separator(), item(Id::Print, "Print").shortcut("Ctrl+P"), ]); let _ = menu.update(Event::Open); let _ = menu.update(Event::Highlight(Id::MoreTools)); Self { menu } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Menu(event) = message; let _ = self.menu.update(event); } pub fn view(&self) -> Element<'_, Message> { let trigger = button("File") .variant(Variant::Outline) .on_press(Message::Menu(Event::Toggle)); container(dropdown_menu(&self.menu, trigger).on_event(Message::Menu)) .width(Length::Fill) .height(Length::Fill) .into() } } ``` ### Keyboard shortcuts The open menu resolves its own keys through the keymap, with typeahead for letters. The app binds Alt+ArrowDown to open it. ```rust use iced::keyboard::key::Named; use iced::widget::{column, container, text}; use iced::{Element, Length, Subscription}; use iced_cube::button; use iced_cube::dropdown_menu::{ self, Action, Event, Output, State, dropdown_menu, item, separator, }; use iced_cube::keys::{self, Chord, Keymap}; use iced_cube::primitives::button::Variant; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Edit { Undo, Redo, Cut, Copy, Paste, SelectAll, } #[derive(Debug, Clone)] pub enum Message { Menu(Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { menu: State, keymap: Keymap, chosen: Option, } impl Default for Example { fn default() -> Self { let mut menu = State::new([ item(Edit::Undo, "Undo").shortcut("Ctrl+Z"), item(Edit::Redo, "Redo").shortcut("Ctrl+Y").disabled(true), separator(), item(Edit::Cut, "Cut").shortcut("Ctrl+X"), item(Edit::Copy, "Copy").shortcut("Ctrl+C"), item(Edit::Paste, "Paste").shortcut("Ctrl+V"), separator(), item(Edit::SelectAll, "Select all").shortcut("Ctrl+A"), ]); let _ = menu.update(Event::First); // Alt+ArrowDown opens the closed menu; the open menu handles the rest. let keymap = dropdown_menu::default_keymap() .bind(Chord::named(Named::ArrowDown).alt(), Action::Open); Self { menu, keymap, chosen: None, } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Menu(event) => Some(event), // Only presses nothing else captured arrive here, so an open // menu has already taken its own keys. Message::Key(key) => self.menu.key_event(&self.keymap, &key), }; if let Some(Output::Activated(edit)) = event.and_then(|event| self.menu.update(event)) { self.chosen = Some(edit); } } pub fn view(&self) -> Element<'_, Message> { let chosen = match self.chosen { Some(edit) => format!("Chose {edit:?}"), None => "Nothing chosen yet".into(), }; let trigger = button("Edit") .variant(Variant::Outline) .on_press(Message::Menu(Event::Toggle)); column![ text("Arrows move, Enter chooses, Escape closes, letters jump. Alt+ArrowDown opens.") .size(14), text(chosen).size(14), container( dropdown_menu(&self.menu, trigger) .keymap(self.keymap.clone()) .on_event(Message::Menu) ) .height(Length::Fill), ] .spacing(12) .width(Length::Fixed(560.0)) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ### In a dialog A menu inside a dialog. A click outside or Escape closes only the menu; the next one closes the dialog. ```rust use iced::widget::{column, container, row, text}; use iced::{Alignment, Element, Length}; use iced_cube::dropdown_menu::{Event, Output, State, dropdown_menu, radio_item}; use iced_cube::overlay::dialog::{self, dialog}; use iced_cube::primitives::button::Variant; use iced_cube::theme::{Tokens, text_size}; use iced_cube::{button, lucide}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Access { View, Comment, Edit, } impl Access { fn label(self) -> &'static str { match self { Access::View => "Can view", Access::Comment => "Can comment", Access::Edit => "Can edit", } } } #[derive(Debug, Clone)] pub enum Message { Open, Close, Menu(Event), } #[derive(Debug)] pub struct Example { open: bool, menu: State, access: Access, } impl Default for Example { fn default() -> Self { let mut menu = State::new( [Access::View, Access::Comment, Access::Edit] .map(|access| radio_item(access, access.label(), access == Access::View)), ); let _ = menu.update(Event::Open); let _ = menu.update(Event::Highlight(Access::Comment)); Self { open: true, menu, access: Access::View, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Open => self.open = true, // Escape or a click outside closes the menu first, so the // dialog only closes once the menu is gone. Message::Close => self.open = false, Message::Menu(event) => { if let Some(Output::Selected(access)) = self.menu.update(event) { self.access = access; } } } } pub fn view(&self) -> Element<'_, Message> { let trigger = button(self.access.label()) .trailing_icon(lucide!(ChevronDown)) .variant(Variant::Outline) .on_press(Message::Menu(Event::Toggle)); let link = row![ column![ text("Anyone with the link").size(text_size::SM), text("No sign-in required") .size(text_size::SM) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .width(Length::Fill), dropdown_menu(&self.menu, trigger) .width(160.0) .on_event(Message::Menu), ] .spacing(12) .align_y(Alignment::Center); let base = container( button("Share") .variant(Variant::Outline) .on_press(Message::Open), ) .padding(24); dialog(base) .open(self.open) .title("Share document") .description("Choose what people with the link can do.") .body(link) .action(button("Done").on_press(Message::Close)) .size(dialog::Size::Sm) .on_dismiss(Message::Close) .into() } } ``` ## API - `State::new(entries)`: Holds the entries, closed. Item ids must be unique, including those in submenus. - `item(id, label)`: An action. Add .icon(glyph), .shortcut("Ctrl+S"), .disabled(true) or .destructive(true). - `checkbox_item(id, label, checked)`: An item with a check mark that toggles when chosen. - `radio_item(id, label, checked)`: One choice among the radio items next to it. Choosing it unchecks the rest of that run. - `submenu(id, label, entries)`: Opens a nested menu to its right, on hover, click, Enter or the right arrow key. - `group_label(text) / separator()`: A heading for the items below it, and a line between groups. - `state.update(Event)`: Handles Open, Close, Toggle, Highlight, Next, Previous, First, Last, OpenSubmenu, CloseSubmenu, Activate, ActivateHighlighted and Typeahead. Returns Output::Activated, Toggled or Selected when an item is chosen, and closes the menu. - `state.is_open() / state.highlighted() / state.depth()`: Whether the menu is open, the highlighted item's id, and how many menus are open including submenus. - `state.item(id) / state.is_checked(id)`: An item by id, and whether a checkbox or radio item is checked. - `state.set_checked(id, bool) / state.set_disabled(id, bool)`: Changes an item from your own state, for example when a setting changes elsewhere. - `dropdown_menu(&state, trigger)`: Renders the trigger with the menu below it while it is open. Items render disabled until .on_event is set. - `.on_event(f)`: Maps menu events to your message. The trigger sends Event::Toggle itself. - `.side(Side) / .align(Align) / .gap(f32)`: Where the menu opens. Defaults to below the trigger, lined up with its left edge. - `.width(f32)`: Width of the menu and its submenus. Defaults to 224. - `.keymap(keymap)`: Replaces the default keymap the open menu resolves its keys with. Unbinding Escape there stops Escape closing the menu. - `overlay::menu::row_style(tokens, RowStatus, destructive)`: The colours of a row when idle, highlighted or disabled, shared by every menu and list, for reuse in custom menus. - `dropdown_menu::default_keymap()`: The default shortcuts as a Keymap. Bind, unbind or clear chords to change them. Open has no default chord. - `state.key_event(&keymap, &key)`: Resolves a key press through the keymap and, while the menu is open, turns letters and digits into typeahead. Route presses from keys::subscription through it to open a closed menu with a chord bound to Action::Open. - `action.event(&state)`: Turns a resolved Action into the Event to pass to state.update, or None when it does nothing. Only Open works while the menu is closed. ## Keyboard - ArrowDown: `Next`. Highlights the next enabled item, wrapping. - ArrowUp: `Previous`. Highlights the previous enabled item, wrapping. - Home: `First`. Highlights the first enabled item. - End: `Last`. Highlights the last enabled item. - Enter or Space: `Activate`. Chooses the highlighted item or opens its submenu. - Escape: `Close`. Closes the innermost submenu, or the menu. - ArrowRight: `OpenSubmenu`. Opens the highlighted submenu. - ArrowLeft: `CloseSubmenu`. Closes the innermost submenu. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/popover.md - https://sam-ruff.github.io/iced-cube/docs/components/select.md - https://sam-ruff.github.io/iced-cube/docs/components/dialog.md --- # Context menu > A menu that opens at the pointer when you right-click an area, or at the finger on a long press. ```rust use iced_cube::context_menu::{self, Event, State, context_menu, keyed}; use iced_cube::dropdown_menu::{Output, checkbox_item, item, separator, submenu}; ``` ## Usage A context menu offers actions for whatever is under the pointer: a file in a list, a selection in an editor, a node on a canvas. It shares its entries, rows and navigation with the [dropdown menu](https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md), so everything on that page applies here too. A right-click inside the area opens the menu with its top left corner at the pointer. It flips up or to the left near the edges of the window, opening on whichever side has room for the whole menu. Only when neither side has room does it take the roomier one and scroll. Right-clicking somewhere else in the area moves the menu there, and a left-click anywhere outside the menu closes it. Clicks inside the menu never reach the area underneath. Touch screens have no right-click, so holding a finger still on the area for half a second opens the menu at the finger instead. Moving the finger more than a few pixels first counts as a scroll and cancels it, and lifting early is an ordinary tap. The lift that ends a long press does not reach the content, so a button in a row is not pressed as the menu opens. A long press is easy to miss, so in a list on a narrow screen consider a visible button that opens the same menu, as the demo's job list does. Keyboard users open it with Shift+F10 or the Menu key, as in desktop apps: route presses from `keys::subscription()` through `state.key_event`, as the second example does. The menu then opens just below the area, lined up with its start so it never covers the area's label, with its first item highlighted. It handles the arrow keys, Enter and Escape itself until it closes. While it is closed, only the opening chords do anything, so a context menu shares the keyboard with the rest of your app. For a list, keep one `State` for every row rather than one per row. Wrap each row with `keyed(&state, row_key, row)`, and the open events tell you which row they came from. Before an open event reaches `update`, you can enable or check items for that row. After an item is chosen, `state.target()` still holds the row, so you know what to act on. Pass the focused row as the target of `key_event`, so Shift+F10 opens the menu below it. ## Examples ### Default A menu opened by a right-click or a long press, with shortcut hints, a submenu and checkbox items. ```rust use iced::widget::{column, container, text}; use iced::{Alignment, Border, Element, Point}; use iced_cube::context_menu::{Event, State, context_menu}; use iced_cube::dropdown_menu::{checkbox_item, item, separator, submenu}; use iced_cube::theme::{Tokens, radius, text_size}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Id { Back, Forward, Reload, MoreTools, SavePage, DevTools, Bookmarks, FullUrls, } #[derive(Debug, Clone)] pub enum Message { Menu(Event), } #[derive(Debug)] pub struct Example { menu: State, } impl Default for Example { fn default() -> Self { let mut menu = State::new([ item(Id::Back, "Back").shortcut("Alt+Left"), item(Id::Forward, "Forward") .shortcut("Alt+Right") .disabled(true), item(Id::Reload, "Reload").shortcut("Ctrl+R"), submenu( Id::MoreTools, "More tools", [ item(Id::SavePage, "Save page as").shortcut("Ctrl+S"), item(Id::DevTools, "Developer tools"), ], ), separator(), checkbox_item(Id::Bookmarks, "Show bookmarks", true), checkbox_item(Id::FullUrls, "Show full URLs", false), ]); let _ = menu.update(Event::Open((), Point::new(290.0, 24.0))); Self { menu } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Menu(event) = message; let _ = self.menu.update(event); } pub fn view(&self) -> Element<'_, Message> { let hint = column![ text("Right-click here").size(text_size::SM), text("or press and hold").size(text_size::XS), ] .spacing(2) .align_x(Alignment::Center); let area = container(hint).center_x(400).center_y(200).style(|theme| { let tokens = Tokens::of(theme); container::Style { text_color: Some(tokens.muted_foreground), border: Border { color: tokens.border, width: 1.0, radius: radius::LG.into(), }, ..container::Style::default() } }); context_menu(&self.menu, area) .on_event(Message::Menu) .into() } } ``` ### One menu for every row A file list shares one menu. Up and Down pick a file, and Shift+F10 or the Menu key opens its menu below it. ```rust use iced::keyboard::Key; use iced::keyboard::key::Named; use iced::widget::{column, container, mouse_area, text}; use iced::{Border, Element, Length, Subscription}; use iced_cube::context_menu::{self, Action, Event, State, keyed}; use iced_cube::dropdown_menu::{Output, item, separator}; use iced_cube::keys::{self, Keymap}; use iced_cube::theme::{Tokens, radius, text_size}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Id { Open, Rename, Duplicate, Delete, } const FILES: [&str; 4] = ["notes.md", "budget.xlsx", "photo.png", "report.pdf"]; #[derive(Debug, Clone)] pub enum Message { Select(usize), Menu(Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { selected: usize, // One menu for every row. Its open events carry the row's index. menu: State, keymap: Keymap, last: String, } impl Default for Example { fn default() -> Self { let mut menu = State::new([ item(Id::Open, "Open").shortcut("Enter"), item(Id::Rename, "Rename").shortcut("F2"), item(Id::Duplicate, "Duplicate").shortcut("Ctrl+D"), separator(), item(Id::Delete, "Delete").shortcut("Del").destructive(true), ]); let _ = menu.update(Event::OpenFromKeyboard(1)); Self { selected: 1, menu, keymap: context_menu::default_keymap(), last: "Nothing chosen yet".into(), } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Select(row) => { self.selected = row; None } Message::Menu(event) => Some(event), // The open menu takes its own keys, so only the opening chord // and the row keys arrive here. Message::Key(key) => self.key(&key), }; let Some(event) = event else { return; }; if let Event::Open(row, _) | Event::OpenFromKeyboard(row) = event { self.selected = row; } let chosen = self.menu.update(event); let (Some(Output::Activated(id)), Some(file)) = (chosen, self.target_file()) else { return; }; self.last = format!("{id:?} {file}"); } /// The file the menu last opened on. fn target_file(&self) -> Option<&'static str> { self.menu.target().and_then(|&row| FILES.get(row)).copied() } fn key(&mut self, key: &keys::Event) -> Option> { let event = self.menu.key_event(&self.keymap, key, self.selected); if event.is_some() || self.menu.is_open() { return event; } match key.key { Key::Named(Named::ArrowDown) => { self.selected = (self.selected + 1).min(FILES.len() - 1) } Key::Named(Named::ArrowUp) => self.selected = self.selected.saturating_sub(1), _ => {} } None } pub fn view(&self) -> Element<'_, Message> { let rows = FILES.iter().enumerate().map(|(index, name)| { let selected = index == self.selected; let row = mouse_area( container(text(*name).size(text_size::SM)) .padding([8, 12]) .width(Length::Fill) .style(move |theme| { let tokens = Tokens::of(theme); container::Style { background: selected.then(|| tokens.accent.into()), border: Border { radius: radius::MD.into(), ..Border::default() }, ..container::Style::default() } }), ) .on_press(Message::Select(index)); keyed(&self.menu, index, row) .keymap(self.keymap.clone()) .on_event(Message::Menu) .into() }); column![ text("Up and Down pick a file. Shift+F10 or the Menu key opens its menu below it.") .size(14), text(&self.last).size(14), column(rows).spacing(2).width(Length::Fill), ] .spacing(12) .width(360) .height(Length::Fill) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `State::new(entries)`: Holds the entries, closed. Build them with the dropdown menu's item, checkbox_item, radio_item, submenu, group_label and separator. State serves one area; State serves many areas told apart by Key. - `state.update(Event)`: Open(key, point) opens at a right-click or long press on the area key, OpenFromKeyboard(key) opens below that area on the first item, and Menu(event) navigates as a dropdown menu does. Returns the dropdown menu's Output when an item is chosen. The key is () for a single area. - `state.is_open() / state.is_open_on(&key) / state.target()`: Whether the menu is open, whether it is open on one area, and the area it last opened on, which stays set after it closes so you know which row a chosen item belongs to. - `state.position() / state.menu()`: Where a right-click opened the menu relative to the area, or None when it opened from the keyboard, and the underlying dropdown menu state. - `state.is_checked(id) / state.set_checked(id, bool) / state.set_disabled(id, bool)`: Reads and changes items, as on a dropdown menu. - `context_menu(&state, content)`: Makes the content an area that opens the menu on a right-click or a long press. Neither does anything until .on_event is set. - `LONG_PRESS / LONG_PRESS_SLOP`: How long a finger rests on the area before the menu opens (500 ms), and how far it may drift meanwhile (10 logical pixels). - `keyed(&state, key, content)`: Makes the content one of many areas sharing one menu, such as a row in a list. The menu shows on it only while open on key, and its open events carry key. - `.on_event(f)`: Maps context menu events to your message. - `.width(f32)`: Width of the menu and its submenus. Defaults to 224. - `.keymap(keymap)`: Replaces the default keymap the open menu resolves its keys with. - `context_menu::default_keymap()`: The dropdown menu's shortcuts plus Open. Bind, unbind or clear chords to change them. - `state.key_event(&keymap, &key, target)`: Resolves a key press through the keymap and, while the menu is open, turns letters and digits into typeahead. The opening chord opens the menu on target, such as the focused row, or () for a single area. - `action.event(&state, target)`: Turns a resolved Action into the Event to pass to state.update. Open only works while the menu is closed, and the others only while it is open. ## Keyboard - Shift+F10 or Menu: `Open`. Opens the menu below the area, on its first item. - ArrowDown: `Next`. Highlights the next enabled item, wrapping. - ArrowUp: `Previous`. Highlights the previous enabled item, wrapping. - Home: `First`. Highlights the first enabled item. - End: `Last`. Highlights the last enabled item. - Enter or Space: `Activate`. Chooses the highlighted item or opens its submenu. - Escape: `Close`. Closes the innermost submenu, or the menu. - ArrowRight: `OpenSubmenu`. Opens the highlighted submenu. - ArrowLeft: `CloseSubmenu`. Closes the innermost submenu. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/popover.md --- # Tree > Nested items that expand and collapse, such as folders and files, with selection, checkboxes and children that load later. ```rust use iced_cube::data::tree::{self, Event, Mode, Output, State, loaded, node, tree}; ``` ## Usage A tree shows data with a parent and child structure: folders and files, an outline, groups of permissions. The app owns a `tree::State` holding every node by id and routes the tree's events into it. Nodes are plain data, so the app can read them back with `state.node(id)` when a node is activated or chosen from its menu. Pressing a row highlights it, and in single or multiple mode selects it. In multiple mode Ctrl or Cmd adds or removes one node and Shift selects the range from the last one pressed. In checkbox mode every row has a checkbox: checking a parent checks its children, and a parent with only some children checked shows a dash. Disabled nodes can be seen but not selected, checked, expanded or activated, and a disabled child keeps its state when its parent changes. A node made with `.lazy()` has children that are not known yet. Expanding it shows a loading line and returns `Output::Load` with its id. Fetch the children however you like, on a thread or a task, and send `loaded(id, nodes)` through the sender from `tree::subscription()`. The lazy loading example simulates a server that answers after a short wait. The tree resolves its keys itself, and only while it has focus, which it takes when pressed and gives up on a press elsewhere. That keeps its arrow keys from clashing with tabs, sliders or anything else on the page. Letters and digits jump to the next node starting with them, and `*` expands the highlighted node and all of its siblings. When the tree has a fixed height it scrolls the highlighted row into view as the highlight moves. Rows are 32 pixels tall, as in menus, and long labels end in an ellipsis rather than wrapping, so a narrow sidebar on a phone keeps every row the same height and easy to tap. ## Examples ### File explorer Folders that open and close, file sizes and a count badge, indentation guides, a disabled folder, a name too long to fit and a context menu on every node. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::context_menu; use iced_cube::data::tree::{Event, Output, State, node, tree}; use iced_cube::dropdown_menu::{self, item, separator}; use iced_cube::lucide; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Action { Open, Rename, Delete, } #[derive(Debug, Clone)] pub enum Message { Files(Event), Menu(context_menu::Event), } #[derive(Debug)] pub struct Example { files: State, menu: context_menu::State, status: String, } impl Default for Example { fn default() -> Self { let files = State::new([ node(1, "src").folder().children([ node(2, "components").folder().children([ node(3, "button.rs").file().trailing("4 KB"), node(4, "data_table.rs").file().trailing("38 KB"), node(5, "tree.rs").file().trailing("21 KB"), ]), node(6, "main.rs").file().trailing("2 KB"), node(7, "a_file_whose_name_is_far_too_long_for_the_row.rs").file(), ]), node(8, "assets").folder().badge("3").children([ node(9, "logo.svg").file(), node(10, "fonts") .folder() .children([node(11, "Inter.ttf").file()]), node(12, "banner.png").file(), ]), node(13, "target").folder().disabled(true), node(14, "Cargo.toml").file().trailing("1 KB"), node(15, "README.md").file(), ]) .with_expanded([1, 2]); let menu = context_menu::State::new([ item(Action::Open, "Open").icon(lucide!(FileText)), item(Action::Rename, "Rename") .icon(lucide!(Pencil)) .shortcut("F2"), separator(), item(Action::Delete, "Delete") .icon(lucide!(Trash)) .destructive(true), ]); Self { files, menu, status: "Double-click opens; right-click or press and hold.".into(), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Files(event) => match self.files.update(event) { Some(Output::Activated(id)) => self.status = format!("Opened {}", self.label(id)), Some(Output::Selected(ids)) => { if let Some(&id) = ids.first() { self.status = format!("Selected {}", self.label(id)); } } _ => {} }, Message::Menu(event) => { let chosen = self.menu.update(event); if let (Some(dropdown_menu::Output::Activated(action)), Some(&id)) = (chosen, self.menu.target()) { self.status = format!("{action:?} {}", self.label(id)); } } } } fn label(&self, id: u32) -> &str { self.files.node(id).map_or("", |node| node.label.as_str()) } pub fn view(&self) -> Element<'_, Message> { column![ tree(&self.files) .guides(true) .height(300) .context_menu(&self.menu, Message::Menu) .on_event(Message::Files), text(&self.status).size(14), ] .spacing(12) .width(Length::Fill) .max_width(340) .into() } } ``` ### Checkboxes Checking a group checks its members, and a group with some members checked shows a dash. The disabled permission keeps its state. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::data::tree::{Event, Mode, State, node, tree}; #[derive(Debug, Clone)] pub enum Message { Permissions(Event), } #[derive(Debug)] pub struct Example { permissions: State, } impl Default for Example { fn default() -> Self { let mut permissions = State::new([ node(1, "Content").children([ node(2, "Read articles"), node(3, "Write articles"), node(4, "Publish articles"), ]), node(5, "Billing").children([ node(6, "View invoices"), node(7, "Manage payment methods").disabled(true), node(8, "Download receipts"), ]), node(9, "Members").children([node(10, "Invite members"), node(11, "Remove members")]), ]) .with_mode(Mode::Checkbox) .with_expanded([1, 5, 9]); let _ = permissions.update(Event::Check(1, true)); let _ = permissions.update(Event::Check(6, true)); Self { permissions } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Permissions(event) => { let _ = self.permissions.update(event); } } } pub fn view(&self) -> Element<'_, Message> { let granted = self .permissions .checked() .into_iter() .filter(|&id| self.permissions.children(id).is_empty()) .count(); column![ tree(&self.permissions).on_event(Message::Permissions), text(format!("{granted} of 10 permissions granted")).size(14), ] .spacing(12) .width(Length::Fill) .max_width(340) .into() } } ``` ### Lazy loading Folders fetch their children when first opened. A simulated server sends them through the tree's channel after a short wait, using futures-timer so it also runs in the browser. ```rust use std::hash::{Hash, Hasher}; use std::time::Duration; use futures_timer::Delay; use iced::futures::{SinkExt, Stream, stream}; use iced::widget::{column, text}; use iced::{Element, Length, Subscription}; use iced_cube::data::tree::{self, Event, Node, Output, State, loaded, node, tree}; #[derive(Debug, Clone)] pub enum Message { Folders(Event), Finished(u32), } #[derive(Debug)] pub struct Example { folders: State, sender: Option>, /// Folders whose children are on their way. loading: Vec, } impl Default for Example { fn default() -> Self { let mut folders = State::new([ node(1, "Photos").folder().lazy(), node(2, "Documents").folder().lazy(), node(3, "Shared with me").folder().lazy(), ]); // Start with one folder open, so its children load straight away. let loading = match folders.update(Event::Expand(1)) { Some(Output::Load(ids)) => ids, _ => Vec::new(), }; Self { folders, sender: None, loading, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Folders(event) => match self.folders.update(event) { Some(Output::Ready(sender)) => self.sender = Some(sender), Some(Output::Load(ids)) => self.loading.extend(ids), _ => {} }, Message::Finished(id) => self.loading.retain(|&loading| loading != id), } } pub fn view(&self) -> Element<'_, Message> { let status = match self.loading.len() { 0 => "Every open folder has loaded. Expand another.".to_owned(), n => format!("Loading {n} folder(s) from the server..."), }; column![ tree(&self.folders).height(280).on_event(Message::Folders), text(status).size(14), ] .spacing(12) .width(Length::Fill) .max_width(340) .into() } pub fn subscription(&self) -> Subscription { let loads = self.sender.iter().flat_map(|sender| { self.loading.iter().map(|&parent| { let load = Load { parent, sender: sender.clone(), }; Subscription::run_with(load, Load::run) }) }); Subscription::batch( std::iter::once(tree::subscription().map(Message::Folders)).chain(loads), ) } } /// A simulated request for one folder's children. It only holds a sender, /// as a worker thread or a network task would. #[derive(Debug, Clone)] struct Load { parent: u32, sender: tree::Sender, } impl Hash for Load { fn hash(&self, state: &mut H) { self.parent.hash(state); } } impl Load { fn run(&self) -> impl Stream + use<> { let parent = self.parent; let mut sender = self.sender.clone(); stream::once(async move { Delay::new(Duration::from_millis(900)).await; let _ = sender.send(loaded(parent, children(parent))).await; Message::Finished(parent) }) } } /// Two subfolders and a file, down to three levels. fn children(parent: u32) -> Vec> { let depth = parent.ilog10(); let file = node(parent * 10 + 3, format!("notes-{parent}.txt")).file(); if depth >= 2 { return vec![file]; } vec![ node(parent * 10 + 1, "2025").folder().lazy(), node(parent * 10 + 2, "Archive").folder().lazy(), file, ] } ``` ### Keyboard shortcuts A multiple selection driven by the default keymap with J and K added. The tree resolves keys only while it has focus. ```rust use iced::keyboard::Modifiers; use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::data::tree::{self, Action, Event, Mode, Output, State, node, tree}; use iced_cube::keys::{Chord, Keymap}; #[derive(Debug, Clone)] pub enum Message { Outline(Event), } #[derive(Debug)] pub struct Example { outline: State, keymap: Keymap, last: String, } impl Default for Example { fn default() -> Self { let mut outline = State::new([ node(1, "Getting started").children([ node(2, "Installation"), node(3, "Quick tour"), node(4, "Migrating").disabled(true), ]), node(5, "Guides").children([ node(6, "Theming"), node(7, "Keyboard shortcuts"), node(8, "Streaming data"), ]), node(9, "Reference").children([ node(10, "Components").children([node(11, "Tree"), node(12, "Data table")]), node(13, "Changelog"), ]), ]) .with_mode(Mode::Multiple) .with_expanded([1]); let _ = outline.update(Event::Press(3, Modifiers::empty())); // The defaults, plus J and K, which take precedence over typeahead. let keymap = tree::default_keymap() .bind(Chord::character('j'), Action::Next) .bind(Chord::character('k'), Action::Previous); Self { outline, keymap, last: "Nothing opened yet".into(), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Outline(event) => { if let Some(Output::Activated(id)) = self.outline.update(event) { let label = self.outline.node(id).map_or("", |node| node.label.as_str()); self.last = format!("Opened \"{label}\""); } } } } pub fn view(&self) -> Element<'_, Message> { let selected = self.outline.selected().len(); column![ text( "Click the outline, then use the arrow keys or J and K. Right expands, Left \ collapses, * expands every sibling, Shift+arrows extend the selection and \ letters jump." ) .size(14), tree(&self.outline) .keymap(self.keymap.clone()) .height(224) .on_event(Message::Outline), text(format!("{selected} selected. {}", self.last)).size(14), ] .spacing(12) .width(Length::Fill) .max_width(420) .into() } } ``` ## API - `State::new(nodes)`: Holds the nodes, keyed by id, all collapsed. Ids must be unique across the whole tree. - `node(id, label)`: A leaf. Add .children(nodes), .lazy() for children that load later, .folder() or .file() for the usual icons, .icon(glyph) or .icons(closed, open), .badge(text) or .trailing(text) at the end of the row, and .disabled(true). - `.with_mode(Mode) / .with_expanded(ids)`: Mode::Single (the default), Multiple, Checkbox or None, and the nodes to start expanded. - `state.update(Event)`: Handles presses, expanding, keyboard navigation, checkboxes and arriving children. Returns Output::Load(ids) when lazy nodes need their children, Activated(id), Selected(ids) or Checked(ids). - `state.selected() / state.checked() / state.check_state(id)`: The selected or checked nodes in tree order, and whether a node's box is checked, unchecked or indeterminate. - `state.visible() / state.highlighted() / state.is_expanded(id)`: The nodes on screen in order, the node the keyboard acts on, and whether a node is open. - `state.node(id) / state.children(id) / state.parent(id)`: Reads the nodes and their structure. - `tree::subscription() / tree::Sender / loaded(parent, children)`: The channel lazy children arrive on. It sends Event::Ready(sender) first; producers send loaded(parent, nodes) through clones of the sender, and batches arrive as Event::Received. - `state.set_loading(id)`: Shows a node as loading, such as while the app refreshes it. - `tree(&state)`: Renders the rows. Rows render disabled until .on_event is set. - `.guides(bool)`: Draws a vertical line at each level of indentation. - `.width(length) / .height(length)`: A fixed or filling height scrolls the rows. Only the rows in view are built. - `.context_menu(&menu, f)`: Gives every node the same context menu, keyed by node id, opened by a right-click, a long press or Shift+F10 on the highlighted node. The node it opened on carries the ring while it is open. - `.keymap(keymap) / .context_menu_keymap(keymap)`: Replaces the tree's or the context menu's default keymap. - `tree::default_keymap() / action.event(&state)`: The default shortcuts, and the Event a resolved Action sends. State::key_event does both, with typeahead. - `tree::row_style(tokens, Status)`: The row colours, taken from the shared menu rows: selected rows use the accent, the keyboard highlight a ring. ## Keyboard - ArrowDown: `Next`. Highlights the next visible node. - ArrowUp: `Previous`. Highlights the previous visible node. - Home: `First`. Highlights the first node. - End: `Last`. Highlights the last visible node. - ArrowRight: `Expand`. Expands the highlighted node, or moves to its first child when it is open. - ArrowLeft: `Collapse`. Collapses the highlighted node, or moves to its parent when it is closed. - Enter: `Activate`. Activates the highlighted node. - Space: `Select`. Selects, toggles or checks the highlighted node. - *: `ExpandSiblings`. Expands the highlighted node and all of its siblings. - Shift+ArrowDown: `ExtendNext`. Moves down and extends the selection, in a multiple selection. - Shift+ArrowUp: `ExtendPrevious`. Moves up and extends the selection, in a multiple selection. - Ctrl+A: `SelectAll`. Selects every visible node, in a multiple selection. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/data-table.md - https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/checkbox.md --- # Data table > Records in rows and columns that readers can sort, search, filter, select and page through, and that turn into cards on a phone. ```rust use iced_cube::data::data_table::{self, Align, Event, Output, State, data_table}; ``` ## Usage A data table shows records that share the same fields, such as payments or invoices. `State` is generic over your own row type: the columns read each row through plain functions, so sorting, searching and filtering work on your data without copying it into strings first. The app owns the state, routes the table's events into it and acts on the outputs. Pressing a header sorts by that column, then reverses the order, then restores the original order. The search box looks through every visible searchable column, and a filterable column adds a select of its values to the toolbar. The Columns menu hides and shows columns, but always leaves one. The footer changes the page size and turns the pages, and only the rows of the current page that are in view are built, so a table of thousands of rows stays quick. With selection on, every row has a checkbox and the header has one that selects or clears the rows on the current page only. The selection belongs to row keys, so it survives sorting, filtering and turning pages. Build bulk actions from `state.selected()` next to the table, as the selection example does. For actions on one row, pass one `context_menu::State` for every row with `.context_menu`. It opens on a right-click or a long press, and `.row_actions(true)` adds a visible button for touch screens. While the table has focus, Shift+F10 opens the menu on the highlighted row. The row the menu opened on carries the ring while it is open. The table takes focus when pressed and resolves its keys only then, so its arrow keys, Space and Escape never clash with other components. The highlight moves on to the next page after the last row, and scrolls into view in a table with a fixed height. Below its breakpoint the table becomes a stack of cards, one per row: the first column heads each card, and the other columns become labelled lines. A select above the cards takes over from the sortable headers. Nothing scrolls sideways or runs off the edge of a phone screen. ## Examples ### Payments Sortable headers, a search box, a status filter, a Columns menu, status badges and right-aligned amounts. ```rust use iced::{Element, Length}; use iced_cube::badge; use iced_cube::data::data_table::{self, Align, Event, State, data_table}; use iced_cube::feedback::badge::Variant; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Status { Paid, Pending, Processing, Failed, } impl Status { fn label(self) -> &'static str { match self { Status::Paid => "Paid", Status::Pending => "Pending", Status::Processing => "Processing", Status::Failed => "Failed", } } fn badge(self) -> Variant { match self { Status::Paid => Variant::Success, Status::Pending => Variant::Secondary, Status::Processing => Variant::Outline, Status::Failed => Variant::Destructive, } } } #[derive(Debug, Clone)] pub struct Payment { id: &'static str, status: Status, email: &'static str, amount: f64, } const PAYMENTS: [Payment; 6] = [ Payment { id: "PAY-3021", status: Status::Paid, email: "ken99@example.com", amount: 316.0, }, Payment { id: "PAY-3022", status: Status::Paid, email: "abe45@example.com", amount: 242.0, }, Payment { id: "PAY-3023", status: Status::Processing, email: "monserrat44@example.com", amount: 837.0, }, Payment { id: "PAY-3024", status: Status::Failed, email: "silas22@example.com", amount: 874.5, }, Payment { id: "PAY-3025", status: Status::Pending, email: "carmella@example.com", amount: 721.0, }, Payment { id: "PAY-3026", status: Status::Paid, email: "jordan.lee@example.com", amount: 1250.0, }, ]; #[derive(Debug, Clone)] pub enum Message { Payments(Event<&'static str>), } #[derive(Debug)] pub struct Example { payments: State, } impl Default for Example { fn default() -> Self { let columns = [ data_table::column("status", "Status", |p: &Payment| p.status.label().into()) .width(130) .filterable(true), data_table::column("email", "Email", |p: &Payment| p.email.into()) .width(Length::FillPortion(3)), data_table::column("amount", "Amount", |p: &Payment| p.amount.into()) .align(Align::End) .format(|p| format!("${:.2}", p.amount)), ]; Self { payments: State::new(columns, PAYMENTS, |p| p.id), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Payments(event) => { let _ = self.payments.update(event); } } } pub fn view(&self) -> Element<'_, Message> { data_table(&self.payments) .placeholder("Search payments...") .cell("status", |p: &Payment| { badge(p.status.label()).variant(p.status.badge()).into() }) .on_event(Message::Payments) .into() } } ``` ### Pagination 243 invoices in pages of 10, 25, 50 or 100. The rows scroll under a header that stays put, and only the rows in view are built. ```rust use iced::{Element, Length}; use iced_cube::data::data_table::{self, Align, Event, State, data_table}; #[derive(Debug, Clone)] pub struct Invoice { number: u32, customer: String, method: &'static str, amount: f64, } const CUSTOMERS: [&str; 7] = [ "Fernhill Analytics", "Northwind Traders", "Blue Harbour Ltd", "Oakridge Studio", "Maple & Finch", "Riverside Clinic", "Kestrel Logistics", ]; const METHODS: [&str; 3] = ["Card", "Bank transfer", "Direct debit"]; /// 243 invoices, as a server might return them. fn invoices() -> Vec { (0..243_u32) .map(|index| Invoice { number: 1001 + index, customer: CUSTOMERS[index as usize % CUSTOMERS.len()].to_owned(), method: METHODS[index as usize % METHODS.len()], amount: f64::from((index * 7919) % 5000) / 4.0 + 20.0, }) .collect() } #[derive(Debug, Clone)] pub enum Message { Invoices(Event), } #[derive(Debug)] pub struct Example { invoices: State, } impl Default for Example { fn default() -> Self { let columns = [ data_table::column("number", "Invoice", |i: &Invoice| i.number.into()) .width(110) .format(|i| format!("INV-{}", i.number)), data_table::column("customer", "Customer", |i: &Invoice| { i.customer.clone().into() }) .width(Length::FillPortion(3)), data_table::column("method", "Method", |i: &Invoice| i.method.into()) .width(Length::FillPortion(2)) .filterable(true), data_table::column("amount", "Amount", |i: &Invoice| i.amount.into()) .align(Align::End) .format(|i| format!("${:.2}", i.amount)), ]; Self { invoices: State::new(columns, invoices(), |i| i.number) .with_page_sizes([10, 25, 50, 100]), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Invoices(event) => { let _ = self.invoices.update(event); } } } pub fn view(&self) -> Element<'_, Message> { // A fixed height scrolls the rows under a header that stays put. data_table(&self.invoices) .placeholder("Search invoices...") .height(280) .on_event(Message::Invoices) .into() } } ``` ### Selection and bulk actions A checkbox on every row, and a header checkbox that selects or clears the rows on the page. Buttons above act on the selection. ```rust use iced::widget::{column, row}; use iced::{Alignment, Element, Length}; use iced_cube::data::data_table::{self, Align, Event, State, data_table}; use iced_cube::feedback::badge::Variant as BadgeVariant; use iced_cube::primitives::button::{Size, Variant}; use iced_cube::{badge, button, lucide}; #[derive(Debug, Clone)] pub struct Payment { id: u32, email: &'static str, status: &'static str, amount: f64, } fn payment(id: u32, email: &'static str, status: &'static str, amount: f64) -> Payment { Payment { id, email, status, amount, } } #[derive(Debug, Clone)] pub enum Message { Payments(Event), MarkPaid, Delete, } #[derive(Debug)] pub struct Example { payments: State, } impl Default for Example { fn default() -> Self { let columns = [ data_table::column("email", "Email", |p: &Payment| p.email.into()) .width(Length::FillPortion(3)), data_table::column("status", "Status", |p: &Payment| p.status.into()).width(110), data_table::column("amount", "Amount", |p: &Payment| p.amount.into()) .align(Align::End) .format(|p| format!("${:.2}", p.amount)), ]; let rows = [ payment(1, "ken99@example.com", "Pending", 316.0), payment(2, "abe45@example.com", "Paid", 242.0), payment(3, "monserrat44@example.com", "Pending", 837.0), payment(4, "silas22@example.com", "Failed", 874.5), payment(5, "carmella@example.com", "Pending", 721.0), ]; let mut payments = State::new(columns, rows, |p| p.id).with_selection(true); let _ = payments.update(Event::Select(1, true)); let _ = payments.update(Event::Select(3, true)); Self { payments } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Payments(event) => { let _ = self.payments.update(event); } Message::MarkPaid => { for id in self.payments.selected() { self.payments.update_row(&id, |p| p.status = "Paid"); } let _ = self.payments.update(Event::ClearSelection); } Message::Delete => { let selected = self.payments.selected(); self.payments.retain(|p| !selected.contains(&p.id)); } } } pub fn view(&self) -> Element<'_, Message> { let count = self.payments.selected().len(); let any = count > 0; let actions = row![ badge(format!("{count} selected")).variant(if any { BadgeVariant::Default } else { BadgeVariant::Secondary }), button("Mark as paid") .icon(lucide!(Check)) .variant(Variant::Outline) .size(Size::Sm) .on_press_maybe(any.then_some(Message::MarkPaid)), button("Delete") .icon(lucide!(Trash)) .variant(Variant::Destructive) .size(Size::Sm) .on_press_maybe(any.then_some(Message::Delete)), ] .spacing(8) .align_y(Alignment::Center) .wrap(); column![ actions, data_table(&self.payments) .toolbar(false) .on_event(Message::Payments), ] .spacing(12) .into() } } ``` ### Row actions One context menu shared by every row, opened by a right-click, a long press, the row's menu button or Shift+F10. The progress column is a custom cell. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::context_menu; use iced_cube::data::data_table::{self, Event, State, data_table}; use iced_cube::dropdown_menu::{Output, item, separator}; use iced_cube::feedback::progress::Size as ProgressSize; use iced_cube::{lucide, progress}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Action { CopyId, ViewLogs, Retry, Cancel, } #[derive(Debug, Clone)] pub struct Deploy { id: &'static str, service: &'static str, progress: f32, } const DEPLOYS: [Deploy; 4] = [ Deploy { id: "DEP-771", service: "billing-api", progress: 1.0, }, Deploy { id: "DEP-772", service: "search-indexer", progress: 0.64, }, Deploy { id: "DEP-773", service: "web-frontend", progress: 0.18, }, Deploy { id: "DEP-774", service: "notifications", progress: 0.9, }, ]; #[derive(Debug, Clone)] pub enum Message { Deploys(Event<&'static str>), Menu(context_menu::Event), } #[derive(Debug)] pub struct Example { deploys: State, menu: context_menu::State, last: String, } impl Default for Example { fn default() -> Self { let columns = [ data_table::column("id", "Deploy", |d: &Deploy| d.id.into()).width(100), data_table::column("service", "Service", |d: &Deploy| d.service.into()), data_table::column("progress", "Progress", |d: &Deploy| d.progress.into()) .searchable(false), ]; let menu = context_menu::State::new([ item(Action::CopyId, "Copy deploy ID").icon(lucide!(Copy)), item(Action::ViewLogs, "View logs").icon(lucide!(ScrollText)), item(Action::Retry, "Retry").icon(lucide!(RotateCw)), separator(), item(Action::Cancel, "Cancel deploy") .icon(lucide!(CircleX)) .destructive(true), ]); Self { deploys: State::new(columns, DEPLOYS, |d| d.id), menu, last: "Right-click or press and hold a row, press its menu button, or press Shift+F10." .into(), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Deploys(event) => { let _ = self.deploys.update(event); } Message::Menu(event) => { let chosen = self.menu.update(event); if let (Some(Output::Activated(action)), Some(id)) = (chosen, self.menu.target()) { self.last = format!("{action:?} on {id}"); } } } } pub fn view(&self) -> Element<'_, Message> { column![ data_table(&self.deploys) .toolbar(false) .cell("progress", |d: &Deploy| { progress(d.progress) .size(ProgressSize::Sm) .show_percentage(true) .into() }) .context_menu(&self.menu, Message::Menu) .row_actions(true) .on_event(Message::Deploys), text(&self.last).size(14), ] .spacing(12) .width(Length::Fill) .into() } } ``` ### Loading Placeholder rows while the data loads. The switch toggles them. ```rust use iced::widget::column; use iced::{Element, Length}; use iced_cube::data::data_table::{self, Align, Event, State, data_table}; use iced_cube::primitives::switch::switch; #[derive(Debug, Clone)] pub struct Order { id: u32, customer: &'static str, total: f64, } #[derive(Debug, Clone)] pub enum Message { Orders(Event), Loading(bool), } #[derive(Debug)] pub struct Example { orders: State, loading: bool, } impl Default for Example { fn default() -> Self { let columns = [ data_table::column("id", "Order", |o: &Order| o.id.into()).width(90), data_table::column("customer", "Customer", |o: &Order| o.customer.into()) .width(Length::FillPortion(2)), data_table::column("total", "Total", |o: &Order| o.total.into()) .align(Align::End) .format(|o| format!("${:.2}", o.total)), ]; let rows = [ Order { id: 1042, customer: "Oakridge Studio", total: 189.0, }, Order { id: 1043, customer: "Maple & Finch", total: 64.5, }, Order { id: 1044, customer: "Riverside Clinic", total: 1210.0, }, ]; Self { orders: State::new(columns, rows, |o| o.id), loading: true, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Orders(event) => { let _ = self.orders.update(event); } Message::Loading(loading) => self.loading = loading, } } pub fn view(&self) -> Element<'_, Message> { column![ switch(self.loading) .label("Loading") .on_toggle(Message::Loading), data_table(&self.orders) .toolbar(false) .loading(self.loading) .on_event(Message::Orders), ] .spacing(16) .into() } } ``` ### Empty A message in place of rows when nothing matches, with a button that clears the search. ```rust use iced::{Element, Length}; use iced_cube::data::data_table::{self, Event, State, data_table}; #[derive(Debug, Clone)] pub struct Customer { name: &'static str, plan: &'static str, } #[derive(Debug, Clone)] pub enum Message { Customers(Event<&'static str>), } #[derive(Debug)] pub struct Example { customers: State, } impl Default for Example { fn default() -> Self { let columns = [ data_table::column("name", "Customer", |c: &Customer| c.name.into()) .width(Length::FillPortion(2)), data_table::column("plan", "Plan", |c: &Customer| c.plan.into()).filterable(true), ]; let rows = [ Customer { name: "Oakridge Studio", plan: "Team", }, Customer { name: "Kestrel Logistics", plan: "Enterprise", }, ]; let mut customers = State::new(columns, rows, |c| c.name); // A search that matches nothing, so the empty state shows. let _ = customers.update(Event::Search("northwind".into())); Self { customers } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Customers(event) => { let _ = self.customers.update(event); } } } pub fn view(&self) -> Element<'_, Message> { data_table(&self.customers) .placeholder("Search customers...") .empty("No customers match your search.") .on_event(Message::Customers) .into() } } ``` ### Keyboard shortcuts The default keymap with J and K added. The table resolves keys only while it has focus, and the highlight moves on to the next page after the last row. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::data::data_table::{self, Action, Align, Event, Output, State, data_table}; use iced_cube::keys::{Chord, Keymap}; #[derive(Debug, Clone)] pub struct Ticket { id: u32, title: &'static str, points: u32, } const TITLES: [&str; 8] = [ "Fix login redirect", "Add export to CSV", "Update onboarding copy", "Cache report queries", "Retry failed webhooks", "Dark mode for emails", "Audit log filters", "Remove legacy flags", ]; #[derive(Debug, Clone)] pub enum Message { Tickets(Event), } #[derive(Debug)] pub struct Example { tickets: State, keymap: Keymap, last: String, } impl Default for Example { fn default() -> Self { let columns = [ data_table::column("id", "Ticket", |t: &Ticket| t.id.into()) .width(90) .format(|t| format!("#{}", t.id)), data_table::column("title", "Title", |t: &Ticket| t.title.into()) .width(Length::FillPortion(3)), data_table::column("points", "Points", |t: &Ticket| t.points.into()).align(Align::End), ]; let rows = TITLES.iter().zip(1_u32..).map(|(&title, n)| Ticket { id: 400 + n, title, points: n % 5 + 1, }); let mut tickets = State::new(columns, rows, |t| t.id) .with_selection(true) .with_page_sizes([4, 8]); let _ = tickets.update(Event::Press(402)); // The defaults, plus J and K. let keymap = data_table::default_keymap() .bind(Chord::character('j'), Action::Next) .bind(Chord::character('k'), Action::Previous); Self { tickets, keymap, last: "Nothing opened yet".into(), } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Tickets(event) => { if let Some(Output::Activated(id)) = self.tickets.update(event) { self.last = format!("Opened #{id}"); } } } } pub fn view(&self) -> Element<'_, Message> { column![ text( "Click the table, then use the arrow keys or J and K. Space selects, Enter \ opens, Ctrl+A selects the page, Escape clears and PageDown turns the page." ) .size(14), data_table(&self.tickets) .toolbar(false) .keymap(self.keymap.clone()) .on_event(Message::Tickets), text(&self.last).size(14), ] .spacing(12) .into() } } ``` ## API - `State::new(columns, rows, key)`: Holds your own row type, the columns that read it, and a key function giving each row an identity that survives sorting, such as its id. - `data_table::column(id, header, value)`: A sortable, searchable and hideable column. value reads a Value (text, a number or empty) from a row. Add .width(length), .align(Align::End), .format(fn) for the text shown, .filterable(true) for a filter of its values, .sortable(false), .hideable(false) or .searchable(false). - `.with_selection(bool) / .with_page_sizes(sizes) / .with_sort(id, direction) / .with_hidden(ids)`: Row checkboxes, the page sizes offered (the first is used), a starting sort and columns hidden to start with. - `state.update(Event)`: Handles sorting, searching, filters, pages, selection, the keyboard and the Columns menu. Returns Output::Activated(key) or Output::Selected(keys). - `state.page_rows() / state.selected() / state.highlighted()`: The rows on the current page, every selected key in row order, and the row the keyboard acts on. - `state.set_rows(rows) / state.push(row) / state.update_row(&key, f) / state.retain(f)`: Changes the rows. The sort, filters and the selection of rows that remain are kept. - `state.showing() / state.page() / state.page_count() / state.filtered_len()`: The footer text, such as "Showing 1-10 of 243", and the numbers behind it. - `data_table(&state)`: Renders the toolbar, the table and the footer. Controls render disabled until .on_event is set. - `.cell(id, f)`: Draws a column's cells with your own element, such as a badge or a progress bar. - `.toolbar(bool) / .placeholder(text)`: Shows or hides the search box, column filters and Columns menu, and sets the search placeholder. - `.loading(bool) / .empty(text)`: Placeholder rows while loading, and the message when nothing matches. - `.height(length) / .row_height(f32)`: A fixed or filling height scrolls the rows under a header that stays put. Rows are 44 pixels tall unless set, such as 32 for a compact table. - `.breakpoint(width)`: Below this width the rows become cards. Defaults to 560. - `.context_menu(&menu, f) / .row_actions(bool)`: Gives every row the same context menu, keyed by row key, and a button at the end of each row that opens it. - `.keymap(keymap) / .context_menu_keymap(keymap)`: Replaces the table's or the context menu's default keymap. - `data_table::default_keymap() / action.event(&state)`: The default shortcuts, and the Event a resolved Action sends. State::key_event does both. - `data_table::row_style(tokens, Status)`: The row colours: muted when selected, lighter under the pointer and a ring for the keyboard highlight. ## Keyboard - ArrowDown: `Next`. Highlights the next row, going on to the next page after the last. - ArrowUp: `Previous`. Highlights the previous row, going back a page before the first. - Home: `First`. Highlights the first row on the page. - End: `Last`. Highlights the last row on the page. - PageDown: `NextPage`. Shows the next page. - PageUp: `PreviousPage`. Shows the previous page. - Space: `Select`. Selects or clears the highlighted row. - Enter: `Activate`. Activates the highlighted row. - Ctrl+A: `SelectAll`. Selects every row on the page. - Escape: `ClearSelection`. Clears the selection. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/tree.md - https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/checkbox.md - https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md --- # JSON Viewer > A JSON document as rows that expand and collapse, with values coloured by type, search and copying of values and paths. ```rust use iced_cube::data::json_viewer::{self, Event, MenuItem, NodeId, Output, State, json_viewer}; ``` ## Usage The JSON viewer shows structured data such as an API response, a configuration file or an event payload. The app owns a `json_viewer::State` built from a `serde_json::Value`, or from text with `State::parse`, and routes the viewer's events into it. Objects and arrays show how many members they hold, as `{3}` or `[12]`, and expand with their chevron, a double click or the keyboard. Strings, numbers, booleans and `null` each have their own colour, taken from the theme's tokens so they follow light, dark and custom themes. Booleans use the primary colour in semibold rather than a status colour, so `ok: true` never reads as an error. A long key or value ends in an ellipsis on one line rather than wrapping; copy it to read it in full. The rows sit in a 1px rounded frame, like the property grid's; turn it off with `.frame(false)` when the viewer is already inside a card. The viewer selects nothing, so the row the keyboard acts on carries only a ring, shown while the viewer has focus, as in the tree. The highlighted row shows two buttons: one copies its value as pretty-printed JSON and the other its path, such as `$.users[2].name`. The viewer never writes to the clipboard itself. It returns `Output::CopyValue` or `Output::CopyPath`, and the app writes the text, for example with `iced::clipboard::write`. The same outputs come from the context menu and from Ctrl+C and Ctrl+Shift+C. Searching looks in keys and values, ignoring case. Every match is tinted, the parents of each one open, and typing moves to the nearest match at or after the highlighted row and scrolls it into view. Enter or F3 in the search box, the arrow buttons or F3 anywhere move between matches, Shift+Enter and Shift+F3 move back, and the only-matches toggle hides everything that is not a match or a parent of one. The count beside the box reads "3 of 12" for the current match of all matches, "12 found" before one is current, and "None" when nothing matches, in a slot of its own so the box keeps its width. Ctrl+F returns `Output::FocusSearch`; answer it with `json_viewer::focus_search(&state)`, and Ctrl+F in the box selects its text. Escape in the box clears the search, and a second Escape leaves the box. The log viewer and property grid search the same way. Ctrl+Shift+Right and Ctrl+Shift+Left (Cmd on macOS) expand and collapse everything. Shift with an arrow is left alone, as the other data views use it to extend a selection. The viewer resolves its keys itself, and only while it has focus, which it takes when pressed and gives up on a press elsewhere. The arrows move and open or close rows as in the tree. Enter does what a double click does: in the tree it activates a node, and here it opens or closes an object or array. Space is left free, since the viewer has nothing to select. Only the rows in view are built, so a document of tens of thousands of nodes scrolls and expands as quickly as a small one. ## Examples ### API response A response opened two levels deep, with values coloured by type, a link too long to fit and a context menu on every row. ```rust use iced::widget::column; use iced::{Element, Length, Task}; use iced_cube::context_menu; use iced_cube::data::json_viewer::{self, Event, MenuItem, NodeId, Output, State, json_viewer}; use iced_cube::dropdown_menu; use serde_json::json; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Viewer(Event), Menu(context_menu::Event), } #[derive(Debug)] pub struct Example { response: State, menu: context_menu::State, } impl Default for Example { fn default() -> Self { let response = State::new(json!({ "status": 200, "ok": true, "requestId": "req_8f2c41d7a9", "data": { "user": { "id": 4821, "name": "Ada Lovelace", "email": "ada@example.com", "roles": ["admin", "billing"], "lastLogin": null }, "plan": { "name": "Team", "seats": 12, "price": 49.5 } }, "links": { "self": "https://api.example.com/v2/users/4821?include=plan,roles,preferences", "next": null } })) .with_depth(2); Self { response, menu: json_viewer::menu(), } } } impl Example { pub fn update(&mut self, message: Message) -> Task { match message { Message::Viewer(event) => self.viewer(event), Message::Menu(event) => { let chosen = self.menu.update(event); match (chosen, self.menu.target()) { (Some(dropdown_menu::Output::Activated(item)), Some(&id)) => { self.viewer(item.event(id)) } _ => Task::none(), } } } } fn viewer(&mut self, event: Event) -> Task { match self.response.update(event) { Some(Output::CopyValue(text) | Output::CopyPath(text)) => bridge::copy(text), _ => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { column![ json_viewer(&self.response) .height(352) .context_menu(&self.menu, Message::Menu) .on_event(Message::Viewer), ] .width(Length::Fill) .max_width(560) .into() } } ``` ### Search Searching keys and values highlights every match and opens its parents. The arrows step through matches, and the filter shows only them. ```rust use iced::widget::column; use iced::{Element, Length, Task}; use iced_cube::data::json_viewer::{self, Event, Output, State, json_viewer}; use serde_json::json; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Viewer(Event), } #[derive(Debug)] pub struct Example { team: State, } impl Default for Example { fn default() -> Self { let mut team = State::new(json!({ "team": "Platform", "members": [ { "name": "Grace Hopper", "role": "lead", "timezone": "America/New_York" }, { "name": "Alan Turing", "role": "engineer", "timezone": "Europe/London" }, { "name": "Katherine Johnson", "role": "engineer", "timezone": "America/New_York" }, { "name": "Edsger Dijkstra", "role": "reviewer", "timezone": "Europe/Amsterdam" } ], "oncall": { "primary": "Alan Turing", "secondary": "Grace Hopper" } })); let _ = team.update(Event::Search("america".into())); Self { team } } } impl Example { pub fn update(&mut self, message: Message) -> Task { let Message::Viewer(event) = message; match self.team.update(event) { Some(Output::CopyValue(text) | Output::CopyPath(text)) => bridge::copy(text), Some(Output::FocusSearch) => json_viewer::focus_search(&self.team), None => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { column![ json_viewer(&self.team) .search_bar(true) .height(296) .on_event(Message::Viewer), ] .width(Length::Fill) .max_width(560) .into() } } ``` ### Copy values and paths The highlighted row has buttons to copy its value as JSON or its path, and the context menu does the same. The app writes the text to the clipboard. ```rust use iced::widget::{column, container, text}; use iced::{Element, Length, Task}; use iced_cube::context_menu; use iced_cube::data::json_viewer::{self, Event, MenuItem, NodeId, Output, State, json_viewer}; use iced_cube::dropdown_menu; use iced_cube::layout::card; use iced_cube::theme::{Tokens, text_size}; use serde_json::json; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Viewer(Event), Menu(context_menu::Event), } #[derive(Debug)] pub struct Example { config: State, menu: context_menu::State, copied: String, } impl Default for Example { fn default() -> Self { let mut config = State::new(json!({ "service": "billing-api", "replicas": 3, "env": { "RUST_LOG": "info", "DATABASE_URL": "postgres://billing@db:5432/billing" }, "ports": [8080, 9090] })) .with_depth(1); if let Some(&env) = config.roots().get(2) { let _ = config.update(Event::Press(env)); } Self { config, menu: json_viewer::menu(), copied: "Nothing copied yet".into(), } } } impl Example { pub fn update(&mut self, message: Message) -> Task { match message { Message::Viewer(event) => self.viewer(event), Message::Menu(event) => { let chosen = self.menu.update(event); match (chosen, self.menu.target()) { (Some(dropdown_menu::Output::Activated(item)), Some(&id)) => { self.viewer(item.event(id)) } _ => Task::none(), } } } } fn viewer(&mut self, event: Event) -> Task { match self.config.update(event) { Some(Output::CopyValue(text) | Output::CopyPath(text)) => { self.copied = text.clone(); bridge::copy(text) } _ => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { let clipboard = container( text(&self.copied) .size(text_size::XS) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ) .padding(12) .width(Length::Fill) .height(120) .clip(true) .style(|theme| card::style(&Tokens::of(theme))); column![ json_viewer(&self.config) .height(224) .context_menu(&self.menu, Message::Menu) .on_event(Message::Viewer), text("Clipboard").size(text_size::SM), clipboard, ] .spacing(8) .width(Length::Fill) .max_width(560) .into() } } ``` ### Keyboard shortcuts The default keymap with J and K added. The viewer resolves keys only while it has focus. ```rust use iced::widget::{column, text}; use iced::{Element, Length, Task}; use iced_cube::data::json_viewer::{self, Action, Event, Output, State, json_viewer}; use iced_cube::keys::{Chord, Keymap}; use iced_cube::theme::text_size; use serde_json::json; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Viewer(Event), } #[derive(Debug)] pub struct Example { package: State, keymap: Keymap, last: String, } impl Default for Example { fn default() -> Self { let package = State::new(json!({ "name": "iced-cube", "version": "0.0.3", "private": false, "scripts": { "build": "cargo build", "test": "cargo test --workspace" }, "keywords": ["iced", "gui", "components"], "engines": { "rust": ">=1.88" } })) .with_depth(1); // The defaults, plus J and K. let keymap = json_viewer::default_keymap() .bind(Chord::character('j'), Action::Next) .bind(Chord::character('k'), Action::Previous); Self { package, keymap, last: "Nothing copied yet".into(), } } } impl Example { pub fn update(&mut self, message: Message) -> Task { let Message::Viewer(event) = message; match self.package.update(event) { Some(Output::CopyValue(value)) => { self.last = "Copied the value".into(); bridge::copy(value) } Some(Output::CopyPath(path)) => { self.last = format!("Copied {path}"); bridge::copy(path) } Some(Output::FocusSearch) => json_viewer::focus_search(&self.package), None => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { column![ text( "Click a row, then use the arrows or J and K. Enter opens or closes, \ Ctrl+C copies the value, Ctrl+Shift+C its path, Ctrl+F searches and \ Escape clears the search." ) .size(text_size::SM), json_viewer(&self.package) .search_bar(true) .keymap(self.keymap.clone()) .height(248) .on_event(Message::Viewer), text(&self.last).size(text_size::SM), ] .spacing(12) .width(Length::Fill) .max_width(560) .into() } } ``` ### Large document 1,500 orders, over 15,000 nodes. Only the rows in view are built, so expanding everything stays quick. ```rust use iced::widget::{column, row, text}; use iced::{Element, Length, Task}; use iced_cube::button; use iced_cube::data::json_viewer::{Event, Output, State, json_viewer}; use iced_cube::primitives::button::{Size, Variant}; use iced_cube::theme::text_size; use serde_json::{Value, json}; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Viewer(Event), } #[derive(Debug)] pub struct Example { orders: State, } impl Default for Example { fn default() -> Self { let orders: Vec = (1..=1_500) .map(|number| { let country = ["GB", "DE", "US"][number % 3]; json!({ "id": format!("ORD-{number:05}"), "total": (number * 37 % 900) as f64 + 0.99, "paid": number % 3 != 0, "customer": { "id": number % 120, "country": country }, "items": [number % 7 + 1, number % 5 + 1] }) }) .collect(); Self { orders: State::new(json!({ "count": orders.len(), "orders": orders })).with_depth(2), } } } impl Example { pub fn update(&mut self, message: Message) -> Task { let Message::Viewer(event) = message; match self.orders.update(event) { Some(Output::CopyValue(text) | Output::CopyPath(text)) => bridge::copy(text), _ => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { let control = |label, event| { button(label) .variant(Variant::Outline) .size(Size::Sm) .on_press(Message::Viewer(event)) }; column![ row![ control("Expand all", Event::ExpandAll), control("Two levels", Event::ExpandToDepth(2)), control("Collapse all", Event::CollapseAll), ] .spacing(8) .wrap(), json_viewer(&self.orders) .height(312) .on_event(Message::Viewer), text(format!( "{} nodes, {} rows shown", self.orders.len(), self.orders.visible().len() )) .size(text_size::SM), ] .spacing(12) .width(Length::Fill) .max_width(560) .into() } } ``` ### Parse errors JSON text is parsed as it is typed. Invalid text shows the problem and where it is in place of the rows. ```rust use iced::widget::column; use iced::{Element, Length, Task}; use iced_cube::data::json_viewer::{Event, Output, State, json_viewer}; use iced_cube::input; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Edit(String), Viewer(Event), } #[derive(Debug)] pub struct Example { text: String, parsed: State, } impl Default for Example { fn default() -> Self { let text = r#"{"name": "Ada", "roles": ["admin" "billing"]}"#.to_owned(); Self { parsed: State::parse(&text), text, } } } impl Example { pub fn update(&mut self, message: Message) -> Task { match message { Message::Edit(text) => { self.parsed = State::parse(&text).with_depth(1); self.text = text; Task::none() } Message::Viewer(event) => match self.parsed.update(event) { Some(Output::CopyValue(text) | Output::CopyPath(text)) => bridge::copy(text), _ => Task::none(), }, } } pub fn view(&self) -> Element<'_, Message> { column![ input("Paste JSON", &self.text).on_input(Message::Edit), json_viewer(&self.parsed).on_event(Message::Viewer), ] .spacing(12) .width(Length::Fill) .max_width(560) .into() } } ``` ## API - `State::new(value)`: Holds a serde_json::Value with everything collapsed. The members of an object or array are the top rows; any other value is a single row. - `State::parse(text)`: Parses JSON text. Invalid text gives a viewer that shows the error, with its line and column, in place of the rows; state.error() returns it. - `.with_depth(n) / .with_only_matches(bool) / .with_search_id(id)`: Starts with every object and array fewer than n levels deep expanded, and with a search showing only its matches. The search box's id defaults to json_viewer::SEARCH; give each viewer its own when a window has several. - `json_viewer::focus_search(&state)`: A task that focuses the search box, the answer to Output::FocusSearch. - `state.update(Event)`: Handles presses, expanding and collapsing (one node, a subtree, everything or to a depth), keyboard navigation, search and copying. Returns Output::CopyValue(json), CopyPath(path) or FocusSearch. - `Event::Search(text) / NextMatch / PreviousMatch / OnlyMatches(bool)`: Searches keys and values ignoring case, opens the parents of every match and highlights the nearest one at or after the highlight. OnlyMatches shows only matches, their parents and what is inside them; state.only_matches() and state.is_filtering() read it back. - `Event::ExpandAll / CollapseAll / ExpandToDepth(n)`: Expands or collapses every object and array, or opens those fewer than n levels deep. - `state.path(id) / state.pretty(id) / state.value_at(id)`: A node's path such as $.users[2].name, with ["key"] for keys that are not plain identifiers; its value as pretty-printed JSON; and the value itself. - `state.visible() / state.highlighted() / state.matches() / state.current_match()`: The rows on screen in order, the node the keyboard acts on, every match and the one the search moved to last. - `json_viewer(&state)`: Renders the rows. Without .on_event it renders disabled. - `.search_bar(bool) / .placeholder(text)`: Shows a search box above the rows with the match count, previous and next buttons and an only-matches toggle. The placeholder defaults to "Search". - `.frame(bool)`: Draws a 1px rounded border around the rows, as the property grid and data table have. On by default; turn it off when the viewer already sits in a card or panel. - `.height(length) / .width(length) / .key_width(px)`: A fixed or filling height scrolls the rows, and only the rows in view are built. Keys longer than key_width end in an ellipsis. - `.context_menu(&menu, f)`: Gives every row the same context menu, keyed by node, opened by a right-click, a press and hold or Shift+F10; the row it opened on carries the ring while it is open. json_viewer::menu() builds one with Copy value, Copy path, Expand all and Collapse all; MenuItem::event(id) turns the chosen entry into an Event. - `.keymap(keymap) / .context_menu_keymap(keymap)`: Replaces the viewer's or the context menu's default keymap. - `json_viewer::default_keymap() / action.event(&state)`: The default shortcuts, and the Event a resolved Action sends. State::key_event does both. - `json_viewer::syntax(tokens) / value_colour(tokens, kind) / row_style(tokens, Status)`: The colours of keys and of each kind of value, taken from the theme's success, warning and primary tokens, and the row colours for the keyboard ring and search matches. ## Keyboard - ArrowDown: `Next`. Highlights the next row. - ArrowUp: `Previous`. Highlights the previous row. - Home: `First`. Highlights the first row. - End: `Last`. Highlights the last row. - ArrowRight: `Expand`. Expands the highlighted object or array, or moves to its first member when it is open. - ArrowLeft: `Collapse`. Collapses the highlighted object or array, or moves to its parent when it is closed. - Enter: `Toggle`. Expands or collapses the highlighted object or array. - *: `ExpandSiblings`. Expands the highlighted node and all of its siblings. - Ctrl+Shift+ArrowRight: `ExpandAll`. Expands every object and array. - Ctrl+Shift+ArrowLeft: `CollapseAll`. Collapses every object and array. - Ctrl+C: `CopyValue`. Copies the highlighted value as pretty-printed JSON. - Ctrl+Shift+C: `CopyPath`. Copies the path of the highlighted value. - Ctrl+F: `FocusSearch`. Moves to the search box. - Escape: `ClearSearch`. Clears the search. - F3: `NextMatch`. Highlights the next search match. - Shift+F3: `PreviousMatch`. Highlights the previous search match. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/tree.md - https://sam-ruff.github.io/iced-cube/docs/components/data-table.md - https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md --- # Property grid > A compact two-column inspector for editing named properties, grouped into categories, with validation, reset to default and a description of the highlighted property. ```rust use iced_cube::data::property_grid::{ self, Event, MenuItem, Output, State, choice, colour, custom, number, property_grid, read_only, text, toggle, }; ``` ## Usage A property grid edits the settings of one thing at a time, such as the shape selected on a canvas or a widget in a form designer. The app owns a `property_grid::State` holding every property by id and routes the grid's events into it. Whenever `update` returns `Output::Changed`, copy the new value into your own model. Text, numbers and colours show as plain text until clicked, or until Enter or F2 is pressed on the highlighted row. They then become a text field with their text selected. A valid edit changes the value as it is typed. An invalid one, such as a number above its maximum or a colour that is not hex, keeps what was typed, draws the field with a destructive border and explains the problem under the rows, without changing the value. Enter ends the edit once it is valid, and Escape puts back the value from before it. Leaving an invalid edit keeps its text in place until it is fixed or reset. Toggles and choices change straight away: a checkbox or switch flips, a select picks an option, and Enter on the row does the same from the keyboard. A property with a default shows a dot and a bold name while its value differs, and a reset button at the end of its row. The box above the rows filters properties by name, with a count beside it that reads like the log and JSON viewers' counts: "2 of 5" for the highlighted property of those shown, "5 found" while none of them is highlighted, and "None" when nothing passes, and the two buttons switch between categories and one alphabetical list. Typing keeps the highlight on the nearest shown property at or after it. Enter or F3 in the box moves the highlight to the next shown property and Shift+Enter or Shift+F3 to the previous, skipping headings and wrapping at the ends, so Enter steps rather than edits while the box has text. Escape in the box clears it, a second Escape leaves it, and Ctrl+F in the box selects its text, as in the log viewer and the JSON viewer. In a narrow panel the value column gives up its room first, down to a minimum, so names stay readable; drag the line between the columns to change the split, and a dragged width is kept. Names that still do not fit end in an ellipsis rather than wrapping, so every row keeps the same height. The description under the rows wraps and grows to fit. Right-click a property, press and hold it or press Shift+F10 to open its context menu when the grid has one; the property carries the ring while the menu is open. Ctrl+C copies the highlighted value as it is shown. Colour swatches keep a lighter ring in dark themes, so a near-black colour still shows its edge. The grid selects nothing, so the row the keyboard acts on carries only a ring, shown while the grid has focus, as in the tree and the JSON viewer. The grid resolves its keys itself, and only while it has focus, which it takes when pressed. While a field is being typed into it keeps the keys it uses, such as the arrows that move its cursor, but Up and Down step a number up or down. ## Examples ### Shape inspector The properties of a selected shape under collapsible categories: numbers with units, colours, a choice, switches and checkboxes, a read-only id and an opacity slider the app draws itself. A dot and a bold name mark values that differ from their default, with a button to reset them. Right-click or press and hold a row, or press Shift+F10, to copy or reset its value. ```rust use iced::widget::{column, text}; use iced::{Color, Element, Length, Task}; use iced_cube::context_menu; use iced_cube::data::property_grid::{ self, Entry, Event, MenuItem, Output, Property, State, choice, colour, custom, number, property_grid, read_only, toggle, }; use iced_cube::dropdown_menu; use iced_cube::primitives::slider; use crate::bridge; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Field { Id, X, Y, Width, Height, Rotation, Fill, Stroke, Opacity, Radius, Blend, Visible, Locked, } #[derive(Debug, Clone)] pub enum Message { Shape(Event), Menu(context_menu::Event), Opacity(u8), } #[derive(Debug)] pub struct Example { shape: State, menu: context_menu::State, last: String, } impl Default for Example { fn default() -> Self { let mut shape = State::new([ read_only(Field::Id, "Id", "rect-12") .description("Given to the shape when it was drawn."), number(Field::X, "X", 48.0) .category("Layout") .step(1.0) .unit("px"), number(Field::Y, "Y", 24.0) .category("Layout") .step(1.0) .unit("px"), number(Field::Width, "Width", 160.0) .category("Layout") .min(1.0) .step(1.0) .unit("px") .default(120.0) .description("Width of the bounding box, before rotation."), number(Field::Height, "Height", 96.0) .category("Layout") .min(1.0) .step(1.0) .unit("px") .default(96.0), number(Field::Rotation, "Rotation", 0.0) .category("Layout") .range(-180.0, 180.0) .step(1.0) .unit("°") .default(0.0), colour(Field::Fill, "Fill", Color::from_rgb8(0x3b, 0x82, 0xf6)) .category("Appearance") .default(Color::WHITE), colour(Field::Stroke, "Stroke", Color::from_rgb8(0x1e, 0x29, 0x3b)) .category("Appearance"), custom(Field::Opacity, "Opacity", 80.0) .category("Appearance") .default(100.0) .description("Drawn by the app with a slider."), number(Field::Radius, "Corner radius", 8.0) .category("Appearance") .range(0.0, 64.0) .step(1.0) .unit("px") .default(0.0), choice( Field::Blend, "Blend mode", ["Normal", "Multiply", "Screen", "Overlay"], "Normal", ) .category("Appearance") .default("Normal"), toggle(Field::Visible, "Visible", true) .switch() .category("Behaviour") .default(true), toggle(Field::Locked, "Locked", false) .category("Behaviour") .default(false), ]); let _ = shape.update(Event::Press(Entry::Property(Field::Width))); Self { shape, menu: property_grid::menu(), last: "Click a value; right-click or press and hold a row.".into(), } } } impl Example { pub fn update(&mut self, message: Message) -> Task { match message { Message::Shape(event) => self.shape(event), Message::Menu(event) => { let chosen = self.menu.update(event); property_grid::sync_menu(&mut self.menu, &self.shape); match (chosen, self.menu.target()) { (Some(dropdown_menu::Output::Activated(item)), Some(&id)) => { self.shape(item.event(id)) } _ => Task::none(), } } Message::Opacity(opacity) => { let _ = self.shape.set(Field::Opacity, f64::from(opacity)); self.describe(Field::Opacity); Task::none() } } } fn shape(&mut self, event: Event) -> Task { match self.shape.update(event) { Some(Output::Changed { id, .. } | Output::Reset { id, .. }) => { self.describe(id); Task::none() } Some(Output::CopyValue(value)) => { self.last = format!("Copied {value}"); bridge::copy(value) } None => Task::none(), } } fn describe(&mut self, id: Field) { if let Some(property) = self.shape.property(id) { self.last = format!("{} is now {}", property.name, property.display()); } } pub fn view(&self) -> Element<'_, Message> { column![ property_grid(&self.shape) .custom(Field::Opacity, opacity) .context_menu(&self.menu, Message::Menu) .height(340) .on_event(Message::Shape), text(&self.last).size(14), ] .spacing(12) .width(Length::Fill) .max_width(340) .into() } } fn opacity(property: &Property) -> Element<'_, Message> { let value = property.value.as_number().unwrap_or(100.0) as u8; slider(0..=100, value).on_change(Message::Opacity).into() } ``` ### Filter and sort The box filters properties by name and the buttons switch between categories and one alphabetical list. This one starts sorted by name and filtered to colours. ```rust use iced::widget::{column, text}; use iced::{Color, Element, Length}; use iced_cube::data::property_grid::{ Event, Sort, State, choice, colour, number, property_grid, text as text_property, toggle, }; #[derive(Debug, Clone)] pub enum Message { Button(Event), } #[derive(Debug)] pub struct Example { button: State, } impl Default for Example { fn default() -> Self { let mut button = State::new([ text_property(1, "Label", "Save changes").category("Content"), text_property(2, "Tooltip", "Saves the draft").category("Content"), choice(3, "Variant", ["Primary", "Outline", "Ghost"], "Primary").category("Style"), colour(4, "Background colour", Color::from_rgb8(0x18, 0x18, 0x1b)).category("Style"), colour(5, "Text colour", Color::from_rgb8(0xfa, 0xfa, 0xfa)).category("Style"), colour(6, "Border colour", Color::from_rgb8(0xe4, 0xe4, 0xe7)).category("Style"), number(7, "Corner radius", 6.0) .category("Style") .step(1.0) .unit("px"), number(8, "Width", 120.0) .category("Size") .step(1.0) .unit("px"), number(9, "Height", 36.0) .category("Size") .step(1.0) .unit("px"), number(10, "Padding", 16.0) .category("Size") .step(1.0) .unit("px"), toggle(11, "Enabled", true).category("State"), toggle(12, "Visible", true).category("State"), ]) .with_sort(Sort::Alphabetical); let _ = button.update(Event::Search("colour".into())); Self { button } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Button(event) = message; let _ = self.button.update(event); } pub fn view(&self) -> Element<'_, Message> { let shown = self.button.entries().len(); column![ property_grid(&self.button) .description(false) .height(240) .on_event(Message::Button), text(format!("{shown} rows shown")).size(14), ] .spacing(12) .width(Length::Fill) .max_width(340) .into() } } ``` ### Validation and reset An invalid port keeps its draft and shows why under the rows, without changing the value. Escape puts back the old value, and the reset buttons go back to the defaults. ```rust use iced::widget::{column, text}; use iced::{Color, Element, Length}; use iced_cube::data::property_grid::{ Event, Output, State, colour, number, property_grid, text as text_property, toggle, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Setting { Host, Port, Timeout, Retries, Secure, Accent, } #[derive(Debug, Clone)] pub enum Message { Settings(Event), } #[derive(Debug)] pub struct Example { settings: State, last: String, } fn host(value: &str) -> Result<(), String> { if value.trim().is_empty() { return Err("Enter a host name".into()); } if value.contains(' ') { return Err("Host names have no spaces".into()); } Ok(()) } impl Default for Example { fn default() -> Self { let mut settings = State::new([ text_property(Setting::Host, "Host", "db.internal") .validate(host) .default("localhost"), number(Setting::Port, "Port", 5432.0) .range(1.0, 65535.0) .step(1.0) .default(5432.0), number(Setting::Timeout, "Timeout", 30.0) .range(0.5, 120.0) .step(0.5) .decimals(1) .unit("s") .default(30.0), number(Setting::Retries, "Retries", 5.0) .range(0.0, 10.0) .step(1.0) .default(3.0), toggle(Setting::Secure, "Require TLS", true).default(true), colour( Setting::Accent, "Status colour", Color::from_rgb8(0x16, 0xa3, 0x4a), ) .default(Color::from_rgb8(0x16, 0xa3, 0x4a)), ]); let _ = settings.update(Event::Edit(Setting::Port)); let _ = settings.update(Event::Input(Setting::Port, "70000".into())); Self { settings, last: "Port stays 5432 until the draft is valid.".into(), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Settings(event) = message; let (id, verb) = match self.settings.update(event) { Some(Output::Changed { id, .. }) => (id, "changed to"), Some(Output::Reset { id, .. }) => (id, "reset to"), Some(Output::CopyValue(_)) | None => return, }; if let Some(property) = self.settings.property(id) { self.last = format!("{} {verb} {}", property.name, property.display()); } } pub fn view(&self) -> Element<'_, Message> { column![ property_grid(&self.settings) .toolbar(false) .on_event(Message::Settings), text(&self.last).size(14), ] .spacing(12) .width(Length::Fill) .max_width(340) .into() } } ``` ### Keyboard shortcuts The default keymap with J and K added. The grid resolves keys only while it has focus. ```rust use iced::widget::{column, text}; use iced::{Element, Length}; use iced_cube::data::property_grid::{ self, Action, Event, Output, State, choice, number, property_grid, text as text_property, toggle, }; use iced_cube::keys::{Chord, Keymap}; #[derive(Debug, Clone)] pub enum Message { Widget(Event), } #[derive(Debug)] pub struct Example { widget: State, keymap: Keymap, last: String, } impl Default for Example { fn default() -> Self { let widget = State::new([ text_property(1, "Name", "search_field").category("General"), text_property(2, "Placeholder", "Search").category("General"), toggle(3, "Enabled", true).category("General").default(true), number(4, "Width", 240.0) .category("Layout") .range(40.0, 800.0) .step(8.0) .unit("px") .default(200.0), number(5, "Margin", 8.0) .category("Layout") .range(0.0, 64.0) .step(1.0) .unit("px"), choice(6, "Align", ["Start", "Centre", "End"], "Start").category("Layout"), ]); // The defaults, plus J and K to move between rows. let keymap = property_grid::default_keymap() .bind(Chord::character('j'), Action::Next) .bind(Chord::character('k'), Action::Previous); Self { widget, keymap, last: "Nothing changed yet".into(), } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Widget(event) = message; if let Some(Output::Changed { id, .. } | Output::Reset { id, .. }) = self.widget.update(event) && let Some(property) = self.widget.property(id) { self.last = format!("{} is now {}", property.name, property.display()); } } pub fn view(&self) -> Element<'_, Message> { column![ text( "Click the grid, then move with the arrows or J and K. Left and Right close \ and open a category, Enter or F2 edits, Up and Down step a number being \ edited, Escape cancels, Delete resets, Ctrl+C copies and Ctrl+F jumps to \ the filter." ) .size(14), property_grid(&self.widget) .keymap(self.keymap.clone()) .height(260) .on_event(Message::Widget), text(&self.last).size(14), ] .spacing(12) .width(Length::Fill) .max_width(360) .into() } } ``` ## API - `State::new(properties)`: Holds the properties by id with their values, categorised with every category open. Ids must be unique. - `text(id, name, value) / number(id, name, value) / toggle(id, name, value)`: Free text, a number and a true or false value. Numbers take .min, .max, .range, .step, .decimals and .unit; a whole step also rejects values between steps. Toggles show a checkbox, or a switch with .switch(). - `choice(id, name, options, selected) / colour(id, name, colour)`: One of a list of options, chosen with a select, and a colour shown as a swatch and edited as hex. - `read_only(id, name, value) / custom(id, name, value)`: Text that cannot be edited, and a value the app draws itself with .custom on the grid. - `.category(name) / .description(text) / .default(value) / .disabled(true)`: Group a property, describe it under the rows, give it a default to reset to, or show it without letting it change. Text properties also take .validate(fn). - `.with_sort(Sort) / .with_collapsed(categories) / .with_name_width(px)`: Sort::Categorised (the default) or Alphabetical, categories to start closed, and a fixed starting width for the name column. - `state.update(Event)`: Handles presses, editing, typing, keyboard navigation, the search (Event::Search, and Event::NextMatch and Event::PreviousMatch to step through what it shows), sorting and resizing. Returns Output::Changed { id, value } whenever a value changes, Output::Reset { id, value } when one goes back to its default and Output::CopyValue(text) for the app to write to the clipboard. - `state.value(id) / state.property(id) / state.set(id, value)`: Reads a value or property, and sets one from the app, such as from a custom editor or after an undo. - `state.editing() / state.draft(id) / state.error(id) / state.can_reset(id)`: The property being typed into, its text, why that text is not valid yet, and whether a reset would change anything. - `state.entries() / state.highlighted() / state.query() / state.sort()`: The rows on screen in order, the row the keyboard acts on, the search text and the sort. - `property_grid(&state)`: Renders the filter box, the rows and the description area. Editors render disabled until .on_event is set. - `.custom(id, render)`: Draws the value of one property with your own element, such as a slider. Store the result with state.set. - `.toolbar(bool) / .description(bool) / .placeholder(text)`: Show or hide the filter and sort buttons and the description area, and change the filter's placeholder, "Filter" by default. Errors show under the rows either way. - `property_grid::focus_search(&state)`: A task that focuses the filter box from the app. Ctrl+F inside the grid does the same on its own. - `.context_menu(&menu, f) / .context_menu_keymap(keymap)`: Gives every property row the same context menu, keyed by property id. property_grid::menu() builds one with Copy value and Reset to default; MenuItem::event(id) turns the chosen entry into an Event, and property_grid::sync_menu(&mut menu, &state) after each menu event disables Reset on a property already at its default. - `.width(length) / .height(length)`: A fixed or filling height scrolls the rows, and only the rows in view are built. - `.keymap(keymap) / property_grid::default_keymap() / action.event(&state)`: Replaces the default shortcuts, and the Event a resolved Action sends. State::key_event does both. - `property_grid::row_style(tokens, Status) / name_width(requested, width)`: The row colours, and the width the name column gets in a row of a given width. ## Keyboard - ArrowDown: `Next`. Highlights the next row, or steps a number being edited down. - ArrowUp: `Previous`. Highlights the previous row, or steps a number being edited up. - Home: `First`. Highlights the first row. - End: `Last`. Highlights the last row. - ArrowRight: `Expand`. Opens the highlighted category, or moves to its first property when it is open. - ArrowLeft: `Collapse`. Closes the highlighted category, or moves from a property to its category. - Enter or F2: `Edit`. Edits the highlighted property: types into text, flips a toggle or picks the next option. - Escape: `Cancel`. Cancels the edit and puts back the old value, or clears the filter. - Delete: `Reset`. Puts the highlighted property back to its default. - Ctrl+C: `CopyValue`. Copies the highlighted value as it is shown. - Ctrl+F: `FocusSearch`. Moves the keyboard to the filter box. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/tree.md - https://sam-ruff.github.io/iced-cube/docs/components/data-table.md - https://sam-ruff.github.io/iced-cube/docs/components/json-viewer.md - https://sam-ruff.github.io/iced-cube/docs/components/input.md - https://sam-ruff.github.io/iced-cube/docs/components/select.md - https://sam-ruff.github.io/iced-cube/docs/components/context-menu.md --- # Virtual list > A long list of equally tall rows that only builds the rows in view, with selection, keyboard navigation and items streamed in from other threads. ```rust use iced_cube::data::virtual_list::{self, Event, Mode, Output, State, virtual_list}; ``` ## Usage A virtual list shows thousands of items without building thousands of widgets. Every row is the same height, so the list knows where each one sits and builds only the rows in view, plus a few either side so scrolling never shows a gap. The app owns a `virtual_list::State` holding the items and routes the list's events into it. The closure passed to `virtual_list` draws one row from its index and item, and only runs for rows in view. Pressing a row highlights it, and in single or multiple mode selects it. In multiple mode Ctrl or Cmd adds or removes one item and Shift selects the range from the last one pressed. Space selects the highlighted item, and a double click or Enter activates it. `.context_menu(&menu, on_event)` gives every row a menu, keyed by its `RowId`; `state.index(id)` turns that back into an index. Give the list a fixed or filling height: one that shrinks to fit has to build every row. Items can stream in from threads or tasks. `virtual_list::subscription()` owns a bounded channel and sends `Event::Ready` with its sender first. Hand clones of the sender to producers, and items arrive in batches, so a burst costs one update rather than one per item. A list made `with_follow(true)` stays scrolled to its newest item. Scrolling up stops following, so the rows stay still while you read, and scrolling back to the end or pressing End starts again. `with_limit(n)` drops the oldest items once the list holds that many, and the rows in view stay where they are as older ones go. The list resolves its keys itself, and only while it has focus, which it takes when pressed and gives up on a press elsewhere. Page Up and Page Down move by the number of rows that fit, which the list reports as its size changes. ## Examples ### 100,000 rows A hundred thousand orders in a multiple selection. Only the rows in view are built, so scrolling stays smooth. ```rust use iced::keyboard::Modifiers; use iced::widget::{column, row, space, text}; use iced::{Element, Length}; use iced_cube::data::virtual_list::{Event, Mode, Output, State, virtual_list}; use iced_cube::theme::{Tokens, text_size}; #[derive(Debug, Clone)] pub enum Message { Orders(Event), } #[derive(Debug, Clone)] pub struct Order { number: u32, customer: &'static str, total: f32, } const CUSTOMERS: [&str; 6] = ["Avery", "Blake", "Casey", "Devon", "Emery", "Finley"]; #[derive(Debug)] pub struct Example { orders: State, selected: usize, } impl Default for Example { fn default() -> Self { let orders = (0..100_000u32).map(|number| Order { number: 10_000 + number, customer: CUSTOMERS[number as usize % CUSTOMERS.len()], total: (number % 97) as f32 * 3.25 + 12.0, }); let mut orders = State::new(orders).with_mode(Mode::Multiple); let _ = orders.update(Event::Press(1, Modifiers::empty())); let _ = orders.update(Event::Press(3, Modifiers::SHIFT)); Self { selected: orders.selected().len(), orders, } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Orders(event) = message; if let Some(Output::Selected(indices)) = self.orders.update(event) { self.selected = indices.len(); } } pub fn view(&self) -> Element<'_, Message> { let list = virtual_list(&self.orders, |_, order: &Order| { row![ text(format!("#{}", order.number)).size(text_size::SM), text(order.customer).size(text_size::SM), space::horizontal(), text(format!("${:.2}", order.total)) .size(text_size::SM) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .spacing(12) .into() }) .height(240) .on_event(Message::Orders); let summary = format!( "{} of 100,000 orders selected. Shift and Ctrl click to select several.", self.selected ); column![list, text(summary).size(text_size::SM)] .spacing(12) .width(Length::Fill) .max_width(420) .into() } } ``` ### Streaming feed Simulated sensors send readings through the list's channel, using futures-timer so they also run in the browser. The list follows the newest reading until you scroll up, and keeps the newest thousand. ```rust use std::hash::{Hash, Hasher}; use std::time::Duration; use futures_timer::Delay; use iced::futures::{SinkExt, Stream, StreamExt, stream}; use iced::widget::{column, row, space, text}; use iced::{Element, Length, Subscription}; use iced_cube::button; use iced_cube::data::virtual_list::{self, Event, Output, State, virtual_list}; use iced_cube::primitives::button::{Size, Variant}; use iced_cube::theme::{Tokens, text_size}; #[derive(Debug, Clone)] pub enum Message { Feed(Event), } #[derive(Debug, Clone)] pub struct Reading { sensor: &'static str, value: f32, } const SENSORS: [&str; 4] = ["Boiler", "Loft", "Garage", "Greenhouse"]; #[derive(Debug)] pub struct Example { feed: State, sensors: Option, } impl Default for Example { fn default() -> Self { // Keeps the newest thousand readings and sticks to the end. let feed = State::new((0..24).map(reading)) .with_limit(1000) .with_follow(true); Self { feed, sensors: None, } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Feed(event) = message; if let Some(Output::Ready(sender)) = self.feed.update(event) { self.sensors = Some(Sensors { sender }); } } pub fn view(&self) -> Element<'_, Message> { let list = virtual_list(&self.feed, |index, reading: &Reading| { row![ text(format!("#{}", self.feed.id(index))) .size(text_size::SM) .width(56), text(reading.sensor).size(text_size::SM), space::horizontal(), text(format!("{:.1} \u{b0}C", reading.value)) .size(text_size::SM) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .spacing(12) .into() }) .height(220) .on_event(Message::Feed); let status: Element<'_, Message> = if self.feed.is_following() { text("Following the newest reading. Scroll up to pause.") .size(text_size::SM) .into() } else { row![ text("Paused while you look back.").size(text_size::SM), space::horizontal(), button("Jump to latest") .size(Size::Sm) .variant(Variant::Secondary) .on_press(Message::Feed(Event::Last)), ] .spacing(12) .align_y(iced::Alignment::Center) .into() }; column![list, status] .spacing(12) .width(Length::Fill) .max_width(420) .into() } pub fn subscription(&self) -> Subscription { let sensors = match &self.sensors { Some(sensors) => Subscription::run_with(sensors.clone(), Sensors::run), None => Subscription::none(), }; Subscription::batch([virtual_list::subscription().map(Message::Feed), sensors]) } } fn reading(tick: u32) -> Reading { Reading { sensor: SENSORS[tick as usize % SENSORS.len()], value: 18.0 + ((tick * 37) % 90) as f32 / 10.0, } } /// Simulated sensors that report a few readings every 400 milliseconds. /// They hold only a sender, as a worker thread would, and wait with /// futures-timer, so they also run in the browser. #[derive(Debug, Clone)] struct Sensors { sender: virtual_list::Sender, } impl Hash for Sensors { fn hash(&self, state: &mut H) { "sensors".hash(state); } } impl Sensors { fn run(&self) -> impl Stream + use<> { let mut sender = self.sender.clone(); stream::once(async move { let mut tick: u32 = 24; loop { Delay::new(Duration::from_millis(400)).await; for _ in 0..3 { if sender.send(reading(tick)).await.is_err() { return; } tick += 1; } } }) .filter_map(|()| async { None:: }) } } ``` ### Keyboard shortcuts The default keymap with J and K added, and a context menu on every row. The list resolves keys only while it has focus. ```rust use iced::keyboard::Modifiers; use iced::widget::{column, text}; use iced::{Element, Length, Task}; use iced_cube::context_menu; use iced_cube::data::virtual_list::{ self, Action, Event, Mode, Output, RowId, State, virtual_list, }; use iced_cube::dropdown_menu::{self, item}; use iced_cube::keys::{Chord, Keymap}; use iced_cube::lucide; use iced_cube::theme::text_size; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Chapters(Event), Menu(context_menu::Event), } #[derive(Debug, Clone, Copy, PartialEq)] pub enum MenuItem { Open, CopyTitle, } #[derive(Debug)] pub struct Example { chapters: State, menu: context_menu::State, keymap: Keymap, last: String, } impl Default for Example { fn default() -> Self { let chapters = (1..=500).map(|n| format!("Chapter {n}")); // The defaults, plus J and K. let keymap = virtual_list::default_keymap() .bind(Chord::character('j'), Action::Next) .bind(Chord::character('k'), Action::Previous); let mut chapters = State::new(chapters).with_mode(Mode::Multiple); let _ = chapters.update(Event::Press(2, Modifiers::empty())); let menu = context_menu::State::new([ item(MenuItem::Open, "Open").icon(lucide!(BookOpen)), item(MenuItem::CopyTitle, "Copy title").icon(lucide!(Copy)), ]); Self { chapters, menu, keymap, last: "Nothing opened yet".into(), } } } impl Example { pub fn update(&mut self, message: Message) -> Task { match message { Message::Chapters(event) => { if let Some(Output::Activated(index)) = self.chapters.update(event) { self.last = format!("Opened {}", self.title(index)); } Task::none() } Message::Menu(event) => { let chosen = self.menu.update(event); let target = self.menu.target().and_then(|&id| self.chapters.index(id)); let (Some(dropdown_menu::Output::Activated(item)), Some(index)) = (chosen, target) else { return Task::none(); }; let title = self.title(index).to_owned(); match item { MenuItem::Open => { self.last = format!("Opened {title}"); Task::none() } MenuItem::CopyTitle => { self.last = format!("Copied {title}"); bridge::copy(title) } } } } } fn title(&self, index: usize) -> &str { self.chapters.get(index).map_or("", String::as_str) } pub fn view(&self) -> Element<'_, Message> { let selected = self.chapters.selected().len(); column![ text( "Click the list, then use the arrow keys or J and K. Page Up and Page Down \ move a screenful, Shift+arrows extend the selection, Space selects and \ Enter opens. Right-click, press and hold, or Shift+F10 opens a row's menu." ) .size(text_size::SM), virtual_list(&self.chapters, |_, title: &String| { text(title.as_str()).size(text_size::SM).into() }) .keymap(self.keymap.clone()) .context_menu(&self.menu, Message::Menu) .height(200) .on_event(Message::Chapters), text(format!("{selected} selected. {}", self.last)).size(text_size::SM), ] .spacing(12) .width(Length::Fill) .max_width(420) .into() } } ``` ## API - `State::new(items)`: Holds the items in Mode::Single with nothing selected. State::default() starts empty. - `.with_mode(Mode) / .with_limit(n) / .with_follow(bool)`: Mode::Single (the default), Multiple or None; the most items kept, dropping the oldest; and whether the list sticks to its end as items arrive. - `state.update(Event)`: Handles presses, keyboard navigation, following and arriving items. Returns Output::Ready(sender) once the channel is open, Activated(index) or Selected(indices). - `state.push(item) / state.extend(items) / state.set_items(items) / state.clear()`: Adds or replaces items directly. Replacing clears the selection and highlight. - `state.get(index) / state.len() / state.iter()`: Reads the items, oldest first. - `state.selected() / state.highlighted() / state.is_following()`: The selected indices in order, the index the keyboard acts on, and whether the list follows its end. - `state.scroll_to(index)`: Scrolls an item into view without highlighting it. - `state.id(index) / state.index(id)`: Converts between an index and the RowId events carry, which stays the same when older items are dropped. - `virtual_list::subscription() / virtual_list::Sender`: The channel items arrive on. It sends Event::Ready(sender) first; producers send items through clones of the sender, and they arrive in batches as Event::Received. - `virtual_list(&state, |index, item| row)`: Renders the rows, each drawn by the closure. Rows render disabled until .on_event is set. - `.row_height(f32) / .width(length) / .height(length)`: Every row's height, 32 by default, and the list's size. A fixed or filling height scrolls the rows. - `.keymap(keymap)`: Replaces the default keymap the focused list resolves keys with. - `.context_menu(&menu, on_event) / .context_menu_keymap(keymap)`: Gives every row a context menu keyed by its RowId, opened with a right-click, a long press or Shift+F10 on the highlighted row, and replaces the menu's keymap. The row it opened on carries the ring while it is open. - `virtual_list::default_keymap() / action.event(&state)`: The default shortcuts, and the Event a resolved Action sends. State::key_event does both. - `virtual_list::row_style(tokens, Status) / frame_style(tokens)`: The row colours, as a tree's rows: the accent behind selected rows, a lighter tint under the pointer and a ring around the keyboard highlight while the list has focus, inside a bordered frame. ## Keyboard - ArrowDown: `Next`. Highlights the next item. - ArrowUp: `Previous`. Highlights the previous item. - Home: `First`. Highlights the first item. - End: `Last`. Highlights the last item, and follows the end in a list that follows. - PageDown: `NextPage`. Moves the highlight down by a page of rows. - PageUp: `PreviousPage`. Moves the highlight up by a page of rows. - Shift+ArrowDown: `ExtendNext`. Moves down and extends the selection, in a multiple selection. - Shift+ArrowUp: `ExtendPrevious`. Moves up and extends the selection, in a multiple selection. - Enter: `Activate`. Activates the highlighted item. - Space: `Select`. Selects or toggles the highlighted item. - Ctrl+A: `SelectAll`. Selects every item, in a multiple selection. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/log-viewer.md - https://sam-ruff.github.io/iced-cube/docs/components/data-table.md - https://sam-ruff.github.io/iced-cube/docs/components/tree.md --- # Log viewer > A live log that streams entries from threads or tasks, with level filters, search, follow, pause and copy, and a cap on how many lines it keeps. ```rust use iced_cube::data::log_viewer::{self, Event, Level, LineId, MenuItem, Output, State, entry, log_viewer}; ``` ## Usage A log viewer shows output from a running program as it happens. The app owns a `log_viewer::State` and routes the viewer's events into it. `log_viewer::subscription()` owns a bounded channel and sends `Event::Ready` with its sender first; hand clones of the sender to threads or tasks and send `entry(level, message)` values through them. Entries arrive in batches, so a burst of lines costs one update rather than one per line. The log keeps the newest 10,000 lines by default and drops the oldest after that, so memory stays bounded however long the program runs. Only the lines in view are built. It follows the newest line until you scroll up, then shows a button under the lines to jump back, with a count of the lines that arrived since. Pausing holds new lines back, bounded by the same limit, until you resume. The level filters are a multiple-choice toggle group: each shows how many kept lines have its level, and is on while that level is shown. The search matches messages, ignoring the case of Latin letters, and marks every match. Typing moves to the nearest match at or after the highlighted line and scrolls it into view; with nothing highlighted it takes the newest match while following. The count beside the field reads "3 of 12" for the current match of all matches, "12 found" before one is current, and "None" when nothing matches, as in the JSON viewer. In the search field Enter or F3 moves to the next match and Shift+Enter or Shift+F3 to the previous, and F3 and Shift+F3 do the same anywhere in the log, wrapping at the ends. Ctrl+F in the field selects its text. Escape in the field clears the search, and a second Escape leaves the field. Long lines end in an ellipsis unless wrapping is on. Below 560 pixels the toolbar stacks and each message sits under its timestamp, level and target, so it keeps the full width. Click, Shift and Ctrl select lines, as do Space and Shift with the arrows, and Ctrl+C or the copy button returns `Output::Copy` with them as plain text, one per line. The library never touches the clipboard itself: write the text with `iced::clipboard::write` or your own platform code. Ctrl+F returns `Output::FocusSearch`; answer it with `log_viewer::focus_search(&state)`. Pause and wrap are toolbar buttons with no default shortcut; bind `Action::TogglePause` or `Action::ToggleWrap` in the keymap to add one. `.context_menu(&menu, on_event)` gives every line a menu, keyed by its `LineId`, opened by a right-click, a press and hold or Shift+F10. The line it opened on carries the ring while the menu is open. `log_viewer::menu()` builds one that copies the line or its message and shows only the line's level; turn the chosen entry into an event with `MenuItem::event(id)`. A finger swipe scrolls the lines, and scrolling away from the newest line stops following. The log resolves its keys while any part of it has focus, the search field included, which keeps the keys typing needs. Timestamps and levels sit in fixed columns so lines line up, and every part uses the font the app registers with `theme::set_font`. ## Examples ### Live log A simulated server writes lines through the log's channel, using futures-timer so it also runs in the browser. Scroll up to stop following, then jump back to the newest line. ```rust use std::hash::{Hash, Hasher}; use std::time::Duration; use futures_timer::Delay; use iced::futures::{SinkExt, Stream, StreamExt, stream}; use iced::{Element, Subscription, Task}; use iced_cube::data::log_viewer::{self, Entry, Event, Level, Output, State, entry, log_viewer}; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Logs(Event), } #[derive(Debug)] pub struct Example { logs: State, server: Option, } impl Default for Example { fn default() -> Self { // Keeps the newest 2,000 lines, so the log never grows without bound. let mut logs = State::new().with_limit(2000); logs.extend((0..14).map(line)); Self { logs, server: None } } } impl Example { pub fn update(&mut self, message: Message) -> Task { let Message::Logs(event) = message; match self.logs.update(event) { Some(Output::Ready(sender)) => { self.server = Some(Server { sender }); Task::none() } Some(Output::Copy(text)) => bridge::copy(text), Some(Output::FocusSearch) => log_viewer::focus_search(&self.logs), None => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { log_viewer(&self.logs) .height(380) .on_event(Message::Logs) .into() } pub fn subscription(&self) -> Subscription { let server = match &self.server { Some(server) => Subscription::run_with(server.clone(), Server::run), None => Subscription::none(), }; Subscription::batch([log_viewer::subscription().map(Message::Logs), server]) } } /// A simulated server writing a few lines every 300 milliseconds. It holds /// only a sender, as a worker thread would, and waits with futures-timer, /// so it also runs in the browser. #[derive(Debug, Clone)] struct Server { sender: log_viewer::Sender, } impl Hash for Server { fn hash(&self, state: &mut H) { "server".hash(state); } } impl Server { fn run(&self) -> impl Stream + use<> { let mut sender = self.sender.clone(); stream::once(async move { let mut tick: u32 = 14; loop { Delay::new(Duration::from_millis(300)).await; for _ in 0..1 + tick % 3 { if sender.send(line(tick)).await.is_err() { return; } tick += 1; } } }) .filter_map(|()| async { None:: }) } } /// The line written at `tick`, with a clock that starts at 09:41. fn line(tick: u32) -> Entry { let (level, target, message) = match tick % 11 { 0 => (Level::Info, "http", "GET /api/orders 200 in 14ms"), 1 => (Level::Debug, "db::pool", "checked out connection 3 of 16"), 2 => (Level::Info, "http", "POST /api/orders 201 in 38ms"), 3 => (Level::Trace, "cache", "hit for key orders:recent"), 4 => ( Level::Warn, "db::pool", "slow query took 840ms: select * from orders where status = 'open'", ), 5 => (Level::Info, "worker", "sent 12 receipts"), 6 => (Level::Debug, "http", "keep-alive connection reused"), 7 => ( Level::Error, "payments", "request to the payment provider timed out after 5s", ), 8 => (Level::Info, "http", "GET /api/health 200 in 1ms"), 9 => (Level::Trace, "cache", "evicted 4 stale entries"), _ => (Level::Info, "worker", "queue is empty, waiting"), }; let millis = 41 * 60_000 + tick * 300; let timestamp = format!( "09:{:02}:{:02}.{:03}", millis / 60_000, millis / 1000 % 60, millis % 1000 ); entry(level, message).timestamp(timestamp).target(target) } ``` ### Levels and search Trace and debug lines are hidden and long lines wrap. The search marks every match, and Enter in the field, F3 or the arrows move between them. ```rust use iced::{Element, Task}; use iced_cube::data::log_viewer::{self, Event, Level, Output, State, entry, log_viewer}; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Logs(Event), } #[derive(Debug)] pub struct Example { logs: State, } impl Default for Example { fn default() -> Self { // Trace and debug lines are hidden, and the search marks every // line that mentions a timeout, starting at the first. let mut logs = State::new() .with_levels([Level::Info, Level::Warn, Level::Error]) .with_wrap(true); logs.extend((0..240).map(|n| { let (level, message) = match n % 8 { 0 => (Level::Info, format!("job {n} started")), 1 => (Level::Debug, format!("job {n} read 3 inputs")), 2 => (Level::Trace, format!("job {n} polled the queue")), 3 => ( Level::Info, format!("job {n} finished in {}ms", 40 + n % 50), ), 4 => ( Level::Warn, format!( "job {n} is retrying after a timeout talking to the storage \ service, attempt 2 of 5, backing off for 800ms" ), ), 5 => (Level::Info, format!("uploaded report {n}")), 6 => ( Level::Error, format!("job {n} failed: timeout waiting for a lock"), ), _ => (Level::Info, format!("job {n} cleaned up")), }; entry(level, message).target("scheduler") })); let _ = logs.update(Event::Search("timeout".into())); let _ = logs.update(Event::NextMatch); Self { logs } } } impl Example { pub fn update(&mut self, message: Message) -> Task { let Message::Logs(event) = message; match self.logs.update(event) { Some(Output::Copy(text)) => bridge::copy(text), Some(Output::FocusSearch) => log_viewer::focus_search(&self.logs), _ => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { log_viewer(&self.logs) .height(380) .on_event(Message::Logs) .into() } } ``` ### Keyboard shortcuts The default keymap with J and K added, and a context menu on every line. The log resolves keys while any part of it has focus, and copying hands the text to the app. ```rust use iced::widget::{column, text}; use iced::{Element, Task}; use iced_cube::context_menu; use iced_cube::data::log_viewer::{ self, Action, Event, Level, LineId, MenuItem, Output, State, entry, log_viewer, }; use iced_cube::dropdown_menu; use iced_cube::keys::{Chord, Keymap}; use iced_cube::theme::text_size; use crate::bridge; #[derive(Debug, Clone)] pub enum Message { Logs(Event), Menu(context_menu::Event), } #[derive(Debug)] pub struct Example { logs: State, menu: context_menu::State, keymap: Keymap, copied: String, } impl Default for Example { fn default() -> Self { let mut logs = State::new(); logs.extend((0..120).map(|n| { let level = Level::ALL[n % Level::ALL.len()]; entry(level, format!("step {n} of the nightly backup")).timestamp(format!( "02:{:02}:{:02}.000", n / 60, n % 60 )) })); // The defaults, plus J and K. let keymap = log_viewer::default_keymap() .bind(Chord::character('j'), Action::Next) .bind(Chord::character('k'), Action::Previous); Self { logs, menu: log_viewer::menu(), keymap, copied: "Nothing copied yet".into(), } } } impl Example { pub fn update(&mut self, message: Message) -> Task { match message { Message::Logs(event) => self.logs(event), Message::Menu(event) => { let chosen = self.menu.update(event); match (chosen, self.menu.target()) { (Some(dropdown_menu::Output::Activated(item)), Some(&id)) => { self.logs(item.event(id)) } _ => Task::none(), } } } } fn logs(&mut self, event: Event) -> Task { match self.logs.update(event) { Some(Output::Copy(lines)) => { self.copied = format!("Copied {} line(s)", lines.lines().count()); bridge::copy(lines) } Some(Output::FocusSearch) => log_viewer::focus_search(&self.logs), _ => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { column![ text( "Click a line, then use the arrows or J and K. Space and Shift+arrows select, \ Ctrl+C copies, Ctrl+F searches and F3 steps through matches. Right-click, \ press and hold, or Shift+F10 opens a line's menu." ) .size(text_size::SM), log_viewer(&self.logs) .keymap(self.keymap.clone()) .context_menu(&self.menu, Message::Menu) .height(320) .on_event(Message::Logs), text(&self.copied).size(text_size::SM), ] .spacing(12) .into() } } ``` ## API - `State::new()`: An empty log that keeps 10,000 lines, shows every level and follows the newest line. - `.with_limit(n) / .with_levels(levels) / .with_wrap(bool) / .with_search_id(id)`: The most lines kept, the levels shown, whether long lines wrap, and the search field's widget id when several logs share a screen. - `entry(level, message)`: One line. Add .timestamp(text) and .target(text); the timestamp is shown as given, so the app picks the format. - `state.update(Event)`: Handles arriving entries, presses, the search, filters, follow, pause, wrap, clear and the keyboard. Returns Output::Ready(sender) once the channel is open, Copy(text) for the clipboard and FocusSearch. - `state.extend(entries)`: Appends entries directly, as the channel does. While paused they are held back. - `log_viewer::subscription() / log_viewer::Sender`: The channel entries arrive on. It sends Event::Ready(sender) first; producers send entries through clones of the sender, and they arrive in batches as Event::Received. - `log_viewer::focus_search(&state)`: The task that focuses the search field, for answering Output::FocusSearch. - `log_viewer::match_label(&state)`: The text beside the search field: "3 of 12" for the current match of all matches, "12 found" before one is current, or "None". - `state.levels()`: The level filters as a multiple-choice toggle group, each labelled with its count. - `state.entries() / state.visible() / state.entry(id)`: Every kept entry, the ids of the lines shown, and one entry by id. - `state.count(level) / state.matches() / state.current_match()`: How many kept lines have a level, the lines matching the search, and the match the search last moved to with its position. - `state.selected() / state.can_copy() / state.copy_text()`: The selected lines, whether there is anything to copy, and the text Event::CopySelection returns: the selection, or the highlighted line. - `state.is_following() / state.is_paused() / state.pending() / state.unseen()`: Whether the log follows its newest line, whether it is paused, how many lines are held back, and how many arrived since it stopped following. - `log_viewer(&state)`: Renders the toolbar and the lines. Everything renders disabled until .on_event is set. - `.width(length) / .height(length)`: The log's size, toolbar included. It fills by default. - `.toolbar(bool) / .placeholder(text) / .empty(text)`: Hides the toolbar, which leaves its actions on the keyboard, and sets the search placeholder and what shows with no lines. - `.keymap(keymap)`: Replaces the default keymap the focused log resolves keys with. - `.context_menu(&menu, on_event) / .context_menu_keymap(keymap)`: Gives every line a context menu keyed by its LineId, opened with a right-click, a long press or Shift+F10 on the highlighted line, and replaces the menu's keymap. - `log_viewer::menu() / MenuItem::event(id)`: A ready-made menu (Copy line, Copy message, Show only this level), and the Event the chosen entry sends for the line it opened on. - `log_viewer::default_keymap() / action.event(&state)`: The default shortcuts, and the Event a resolved Action sends. State::key_event does both. - `log_viewer::level_colour(tokens, level) / line_style(tokens, level, current, Status)`: The colour of each level's tag, and a line's colours: list row colours, a tint on error lines and the search marks, stronger on the current match. ## Keyboard - ArrowDown: `Next`. Highlights the next line. - ArrowUp: `Previous`. Highlights the previous line. - Home: `First`. Highlights the oldest line. - End: `Last`. Jumps to the newest line and follows new ones. - PageDown: `NextPage`. Moves the highlight down by a page of lines. - PageUp: `PreviousPage`. Moves the highlight up by a page of lines. - Shift+ArrowDown: `ExtendNext`. Moves down and extends the selection. - Shift+ArrowUp: `ExtendPrevious`. Moves up and extends the selection. - Space: `Select`. Selects or deselects the highlighted line. - Ctrl+A: `SelectAll`. Selects every shown line. - Ctrl+C: `CopySelection`. Copies the selected lines, or the highlighted one. - Ctrl+F: `FocusSearch`. Focuses the search field. - F3: `NextMatch`. Moves to the next line matching the search. - Shift+F3: `PreviousMatch`. Moves to the previous line matching the search. - Escape: `ClearSearch`. Clears the search. - Ctrl+L: `Clear`. Removes every line. - Enter / Shift+Enter, F3 / Shift+F3: In the search field, moves to the next or previous match. - Ctrl+F (Cmd+F on macOS): In the search field, selects its text. - Escape: In the search field, clears the text; a second Escape leaves the field. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/virtual-list.md - https://sam-ruff.github.io/iced-cube/docs/components/status-bar.md - https://sam-ruff.github.io/iced-cube/docs/components/toast.md --- # Alert > An inline callout with a title, description and icon. ```rust use iced_cube::alert; use iced_cube::feedback::alert::Variant; ``` ## Usage Alerts sit in the flow of the page and stay until the content around them changes. Use them for messages that relate to what is on screen, such as a failed sync or a setting that needs attention. Each variant has a default icon: info, a tick, a warning triangle and an exclamation mark. Status variants tint their background and border with the matching theme colour, while Info stays neutral. ## Examples ### Variants Info, success, warning and destructive callouts. ```rust use iced::Element; use iced::widget::row; use iced_cube::alert; use iced_cube::feedback::alert::Variant; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; const ALERTS: [(&str, &str, Variant); 4] = [ ( "New version available", "Restart to update to 2.4.", Variant::Info, ), ( "Backup complete", "All 1,284 files were saved.", Variant::Success, ), ( "Storage almost full", "You have 312 MB left.", Variant::Warning, ), ( "Sync failed", "Check your connection and retry.", Variant::Destructive, ), ]; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { row(ALERTS.map(|(title, description, variant)| { alert(title) .description(description) .variant(variant) .width(276) .into() })) .spacing(12) .wrap() .into() } } ``` ### Custom icon Any Lucide icon in place of the variant's default. ```rust use iced::Element; use iced::widget::column; use iced_cube::feedback::alert::Variant; use iced_cube::{alert, lucide}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { column![ alert("Build cache enabled") .description( "Repeat builds reuse compiled dependencies, so they finish much faster." ) .icon(lucide!(Terminal)), alert("Your session expires in five minutes") .icon(lucide!(Clock)) .variant(Variant::Warning), ] .spacing(12) .width(480) .into() } } ``` ## API - `alert(title)`: Creates an alert with a title. It fills the available width. - `.description(text)`: Adds supporting text under the title. - `.variant(Variant)`: Info, Success, Warning or Destructive. Defaults to Info. - `.icon(glyph)`: Replaces the variant's default icon with any Lucide icon. - `.width(length)`: Overrides the width. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/toast.md - https://sam-ruff.github.io/iced-cube/docs/components/badge.md --- # Badge > A short status or category marker. ```rust use iced_cube::badge; use iced_cube::feedback::badge::Variant; ``` ## Usage Badges are for short labels that sit next to other content: a status in a table row, a tag on a card, a version number. Keep the text to a word or two. Success, Warning and Destructive map to the theme's status colours, so use them only when the badge actually reports a state. For neutral tags, Secondary and Outline stay out of the way. Badges are not interactive. If a label needs to respond to clicks, use a button instead. ## Examples ### Variants Every badge variant, from default to warning. ```rust use iced::widget::row; use iced::{Alignment, Element}; use iced_cube::badge; use iced_cube::feedback::badge::Variant; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { row(Variant::ALL.map(|variant| badge(format!("{variant:?}")).variant(variant).into())) .spacing(8) .align_y(Alignment::Center) .wrap() .into() } } ``` ### With icon A small Lucide icon before the label. ```rust use iced::widget::row; use iced::{Alignment, Element}; use iced_cube::feedback::badge::Variant; use iced_cube::{badge, lucide}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { row![ badge("Verified") .icon(lucide!(BadgeCheck)) .variant(Variant::Secondary), badge("Deployed") .icon(lucide!(CircleCheck)) .variant(Variant::Success), badge("Degraded") .icon(lucide!(TriangleAlert)) .variant(Variant::Warning), badge("Offline") .icon(lucide!(CircleX)) .variant(Variant::Destructive), badge("v2.4.0").icon(lucide!(Tag)).variant(Variant::Outline), ] .spacing(8) .align_y(Alignment::Center) .wrap() .into() } } ``` ### In a list Status badges aligned against list rows. ```rust use iced::widget::{column, row, space, text}; use iced::{Alignment, Element}; use iced_cube::badge; use iced_cube::feedback::badge::Variant; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; const INVOICES: [(&str, &str, Variant); 4] = [ ("INV-1024", "Paid", Variant::Success), ("INV-1025", "Pending", Variant::Secondary), ("INV-1026", "Overdue", Variant::Destructive), ("INV-1027", "Draft", Variant::Outline), ]; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { column(INVOICES.map(|(number, status, variant)| { row![ text(number).size(14), space::horizontal(), badge(status).variant(variant), ] .align_y(Alignment::Center) .into() })) .spacing(12) .width(280) .into() } } ``` ## API - `badge(label)`: Creates a pill-shaped badge with a text label. - `.variant(Variant)`: Default, Secondary, Outline, Destructive, Success or Warning. Defaults to Default. - `.icon(glyph)`: Adds a small Lucide icon before the label. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/alert.md --- # Progress > A determinate progress bar with an optional label and percentage. ```rust use iced_cube::progress; use iced_cube::feedback::progress::{Size, Variant}; ``` ## Usage Use a progress bar when you know how much work is left. When you do not, show a spinner instead. The bar is stateless: keep the value in your app and pass it in on every view. The `clamp` and `percentage` helpers in the module are there if you need the same maths elsewhere. ## Examples ### Variants Labelled bars with a percentage, in every colour. ```rust use iced::Element; use iced::widget::column; use iced_cube::feedback::progress::Variant; use iced_cube::progress; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { column![ progress(0.62) .label("Uploading photos") .show_percentage(true), progress(1.0) .variant(Variant::Success) .label("Backup complete") .show_percentage(true), progress(0.87) .variant(Variant::Warning) .label("Disk usage") .show_percentage(true), progress(0.34) .variant(Variant::Destructive) .label("Import stopped") .show_percentage(true), ] .spacing(16) .width(360) .into() } } ``` ### Sizes Small, medium and large bars. ```rust use iced::Element; use iced::widget::column; use iced_cube::feedback::progress::Size; use iced_cube::progress; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { column![ progress(0.25).size(Size::Sm).label("Small"), progress(0.5).size(Size::Md).label("Medium"), progress(0.75).size(Size::Lg).label("Large"), ] .spacing(20) .width(360) .into() } } ``` ### Interactive The bar follows a value held in app state. ```rust use iced::Element; use iced::widget::{column, row}; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide, progress}; const STEPS: u8 = 10; #[derive(Debug, Clone)] pub enum Message { Step, Reset, } #[derive(Debug)] pub struct Example { step: u8, } impl Default for Example { fn default() -> Self { Self { step: 3 } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Step => self.step = (self.step + 1).min(STEPS), Message::Reset => self.step = 0, } } pub fn view(&self) -> Element<'_, Message> { let done = self.step == STEPS; column![ progress(f32::from(self.step) / f32::from(STEPS)) .label(if done { "Done" } else { "Installing" }) .show_percentage(true), row![ button("Step") .icon(lucide!(Plus)) .on_press_maybe((!done).then_some(Message::Step)), button("Reset") .variant(Variant::Outline) .on_press(Message::Reset), ] .spacing(8), ] .spacing(16) .width(360) .into() } } ``` ## API - `progress(value)`: Creates a bar from a value between 0.0 and 1.0. Values outside that range are clamped, and NaN counts as 0.0. - `.label(text)`: Shows a caption above the bar. - `.show_percentage(bool)`: Shows the rounded percentage above the bar, on the right. - `.variant(Variant)`: Default, Success, Warning or Destructive. Defaults to Default. - `.size(Size)`: Sm, Md or Lg thickness. Defaults to Md. - `.width(length)`: Overrides the width. Bars fill the available width by default. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/spinner.md - https://sam-ruff.github.io/iced-cube/docs/components/slider.md --- # Spinner > An indeterminate loading indicator. ```rust use iced_cube::spinner; use iced_cube::feedback::spinner::{Size, advance}; ``` ## Usage The spinner has no clock of its own. Your app keeps the phase in its state and moves it on with `advance` each frame, which keeps the spinner a pure function of state and makes it easy to test. Subscribe to `iced::window::frames()` only while something is loading, and return `Subscription::none()` otherwise. That way an idle app does not redraw at all. Several spinners can share one phase, as the rows of a job list do. The arc steps through `FRAMES` fixed positions per turn and is drawn as a cached image, so any number of spinners on screen stay cheap and look the same on every renderer, including WebGL in the browser. ## Examples ### Loading Animates only while loading, driven by window frames. ```rust use iced::time::Instant; use iced::widget::{column, row, text}; use iced::{Alignment, Element, Subscription, window}; use iced_cube::feedback::spinner::advance; use iced_cube::primitives::button::Variant; use iced_cube::{button, spinner}; #[derive(Debug, Clone)] pub enum Message { Toggle, Frame(Instant), } #[derive(Debug)] pub struct Example { loading: bool, phase: f32, last_frame: Option, } impl Default for Example { fn default() -> Self { Self { loading: true, phase: 0.15, last_frame: None, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Toggle => { self.loading = !self.loading; self.last_frame = None; } Message::Frame(now) => { if let Some(last) = self.last_frame { self.phase = advance(self.phase, now - last); } self.last_frame = Some(now); } } } pub fn view(&self) -> Element<'_, Message> { let status: Element<'_, Message> = if self.loading { row![spinner(self.phase), text("Fetching latest data").size(14)] .spacing(12) .align_y(Alignment::Center) .into() } else { text("Up to date").size(14).into() }; column![ status, button(if self.loading { "Stop" } else { "Refresh" }) .variant(Variant::Outline) .on_press(Message::Toggle), ] .spacing(16) .align_x(Alignment::Center) .into() } /// Frames are only requested while the spinner is visible. pub fn subscription(&self) -> Subscription { if !self.loading { return Subscription::none(); } window::frames().map(Message::Frame) } } ``` ### Sizes Small, medium and large spinners sharing one phase. ```rust use iced::time::Instant; use iced::widget::row; use iced::{Alignment, Element, Subscription, window}; use iced_cube::feedback::spinner::{Size, advance}; use iced_cube::spinner; #[derive(Debug, Clone)] pub enum Message { Frame(Instant), } #[derive(Debug)] pub struct Example { phase: f32, last_frame: Option, } impl Default for Example { fn default() -> Self { Self { phase: 0.15, last_frame: None, } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Frame(now) = message; if let Some(last) = self.last_frame { self.phase = advance(self.phase, now - last); } self.last_frame = Some(now); } pub fn view(&self) -> Element<'_, Message> { row(Size::ALL.map(|size| spinner(self.phase).size(size).into())) .spacing(24) .align_y(Alignment::Center) .into() } pub fn subscription(&self) -> Subscription { window::frames().map(Message::Frame) } } ``` ## API - `spinner(phase)`: Creates a spinner at a phase measured in turns, from 0.0 to 1.0. - `.size(Size)`: Sm, Md or Lg. Defaults to Md. - `advance(phase, elapsed)`: Returns the phase moved on by the elapsed time. One turn takes PERIOD. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/progress.md --- # Avatar > A small picture of a person or team, with initials when there is no image. ```rust use iced_cube::{avatar, avatar_group}; use iced_cube::feedback::avatar::{Presence, Shape, Size}; ``` ## Usage An avatar shows the first thing it has of an image, an icon and the name's initials, so the same code covers people who have uploaded a picture and those who have not. Initials and icons sit on the muted surface. Images come from an iced `image::Handle`. The `avatar` feature turns on iced's image widget without any decoders, which is enough for pixels you already have, such as a picture fetched and decoded elsewhere. To load PNG or JPEG files directly, enable iced's `image` feature in your app. The GPU renderer crops images to the avatar's shape; iced's software fallback draws them with square corners. The rings around grouped avatars and presence dots are drawn in the page background colour, so they read best on the page itself rather than on a raised card. Presence colours come from the theme's status tokens: success for online, warning for away, destructive for busy and the muted foreground for offline. Pair the dot with text, as the presence example does, since colour alone does not say which state it is. ## Examples ### Image, initials and icon An image when there is one, otherwise the name's initials, an icon, or a person icon when there is no name. ```rust use iced::widget::{column, image, row, text}; use iced::{Alignment, Element}; use iced_cube::feedback::avatar::Size; use iced_cube::{avatar, lucide}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug)] pub struct Example { photo: image::Handle, } impl Default for Example { fn default() -> Self { Self { photo: gradient([236, 72, 153], [99, 102, 241]), } } } impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let labelled = |avatar: Element<'static, Message>, label: &'static str| { column![avatar, text(label).size(12)] .spacing(6) .align_x(Alignment::Center) }; row![ labelled( avatar("Ada Lovelace") .image(self.photo.clone()) .size(Size::Lg) .into(), "Image", ), labelled(avatar("Ada Lovelace").size(Size::Lg).into(), "Initials"), labelled( avatar("Release bot") .icon(lucide!(Bot)) .size(Size::Lg) .into(), "Icon", ), labelled(avatar("").size(Size::Lg).into(), "No name"), ] .spacing(24) .wrap() .into() } } /// A diagonal blend between two colours, standing in for a profile photo. fn gradient(from: [u8; 3], to: [u8; 3]) -> image::Handle { const SIDE: u32 = 64; let mut pixels = Vec::with_capacity((SIDE * SIDE * 4) as usize); for y in 0..SIDE { for x in 0..SIDE { let t = (x + y) as f32 / (2 * SIDE - 2) as f32; for channel in 0..3 { let blend = f32::from(from[channel]) * (1.0 - t) + f32::from(to[channel]) * t; pixels.push(blend.round() as u8); } pixels.push(255); } } image::Handle::from_rgba(SIDE, SIDE, pixels) } ``` ### Sizes and shapes Small to extra large, as circles for people and rounded squares for teams. ```rust use iced::widget::{column, row}; use iced::{Alignment, Element}; use iced_cube::avatar; use iced_cube::feedback::avatar::{Shape, Size}; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let line = |shape: Shape, name: &str| { row(Size::ALL.map(|size| avatar(name).size(size).shape(shape).into())) .spacing(12) .align_y(Alignment::Center) }; column![ line(Shape::Circle, "Ada Lovelace"), line(Shape::Square, "Platform team"), ] .spacing(16) .into() } } ``` ### Presence A dot on the corner for online, away, busy and offline. ```rust use iced::widget::{column, row, text}; use iced::{Alignment, Element, Length}; use iced_cube::avatar; use iced_cube::feedback::avatar::Presence; use iced_cube::theme::Tokens; const TEAM: [(&str, &str, Presence); 4] = [ ("Ada Lovelace", "Online", Presence::Online), ("Grace Hopper", "Back in ten minutes", Presence::Away), ("Alan Turing", "In a meeting", Presence::Busy), ("Edsger Dijkstra", "Last seen yesterday", Presence::Offline), ]; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let members = TEAM.map(|(name, status, presence)| { row![ avatar(name).presence(presence), column![ text(name).size(14), text(status).size(12).style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ] .spacing(2), ] .spacing(12) .align_y(Alignment::Center) .into() }); column(members) .spacing(12) .width(Length::Fill) .max_width(280) .into() } } ``` ### Group Overlapping avatars, with a count for the ones that do not fit. ```rust use iced::widget::{column, row, text}; use iced::{Alignment, Element}; use iced_cube::feedback::avatar::Size; use iced_cube::{avatar, avatar_group}; const REVIEWERS: [&str; 7] = [ "Ada Lovelace", "Grace Hopper", "Alan Turing", "Edsger Dijkstra", "Barbara Liskov", "Ken Thompson", "Frances Allen", ]; #[derive(Debug, Clone)] pub enum Message {} #[derive(Debug, Default)] pub struct Example; impl Example { pub fn update(&mut self, _message: Message) {} pub fn view(&self) -> Element<'_, Message> { let line = |size: Size, max: usize, label: &'static str| { row![ avatar_group(REVIEWERS.map(avatar)) .size(size) .max_items(max), text(label).size(14), ] .spacing(12) .align_y(Alignment::Center) }; column![ line(Size::Sm, 5, "Seven reviewers, four shown"), line(Size::Md, 4, "Three shown and a count"), ] .spacing(16) .into() } } ``` ## API - `avatar(name)`: Creates an avatar showing the name's initials. A name with no letters shows a person icon. - `.image(handle)`: Shows a picture instead of the initials, cropped to fill the shape. - `.icon(glyph)`: Shows a Lucide icon instead of the initials, such as a bot for an automated account. - `.size(Size)`: Sm, Md, Lg or Xl: 24, 32, 40 or 56 pixels across. Defaults to Md. - `.shape(Shape)`: Circle for people or Square, with rounded corners, for teams and organisations. Defaults to Circle. - `.presence(Presence)`: Adds a dot for Online, Away, Busy or Offline on the bottom right corner. - `avatar_group(avatars)`: Overlaps avatars from left to right, each ringed in the background colour. - `.size(Size) / .max_items(n)`: Sets the size of every avatar in a group, and shows at most n places, the last one counting the rest. - `avatar::initials(name)`: The initials an avatar shows: the first letters of the first and last words. ## See also - https://sam-ruff.github.io/iced-cube/docs/components/badge.md --- # Menubar > File, Edit, View and Help menus in a strip along the top of a desktop window. ```rust use iced_cube::dropdown_menu::{checkbox_item, item, radio_item, separator, submenu}; use iced_cube::menubar::{self, Output, State, menu, menubar}; ``` ## Usage A menubar is a row of [dropdown menus](https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md) that behave as one. Each menu takes the same entries, so shortcut hints, checkbox and radio items and submenus work exactly as they do there, drawn in the same rows. The app owns a `menubar::State`, routes its events into it and acts on the `Output`, which has already updated the menu's own check marks. Clicking a title opens its menu. Once one menu is open, moving the pointer onto another title switches to that menu, as desktop menus do, and clicking the open title closes it. A click anywhere else closes the menu and goes no further. On a touch screen there is no pointer to move, so the first tap outside closes the open menu and a second tap opens the next. While a menu is open it resolves its own keys: Left and Right move to the neighbouring menu, or open and close a submenu when the highlight is on one, and the arrows, Enter, Escape and typeahead work inside the menu. A closed bar claims no keys by itself. Route presses from `keys::subscription()` through `state.key_event`, and F10, or Alt pressed and released on its own, focuses the first title (Alt used in a chord such as `Alt+ArrowUp` leaves the bar alone); the arrows then move along the bar, Down opens a menu and Escape leaves. While the bar has focus from the keyboard, each title underlines its mnemonic, and that letter opens the menu. Alt with the letter works at any time. The [keyboard guide](https://sam-ruff.github.io/iced-cube/docs/keyboard.md) explains how keys reach each layer. Bare Alt focuses the bar on the key press, so a chord such as Alt+Z in another component also leaves the bar focused. Unbind Alt from the keymap if that clashes with your app, and keep F10. ## Examples ### Editor menus File, Edit, View and Help with shortcut hints, a submenu, checkbox and radio items. Once a menu is open, moving onto another title switches to it. F10 or Alt focuses the bar and the arrows move through it. ```rust use iced::widget::{column, container, text}; use iced::{Element, Length, Subscription}; use iced_cube::dropdown_menu::{ self, checkbox_item, group_label, item, radio_item, separator, submenu, }; use iced_cube::keys::{self, Keymap}; use iced_cube::lucide; use iced_cube::menubar::{self, Action, Output, State, menu, menubar}; use iced_cube::theme::{Tokens, text_size}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Command { New, Open, Recent, Notes, Todo, Save, Quit, Undo, Redo, Cut, Copy, Paste, WordWrap, LineNumbers, Small, Medium, Large, Shortcuts, About, } #[derive(Debug, Clone)] pub enum Message { Menubar(menubar::Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { menubar: State, keymap: Keymap, menu_keymap: Keymap, status: String, } impl Default for Example { fn default() -> Self { let mut menubar = State::new([ menu( "File", [ item(Command::New, "New file") .icon(lucide!(FilePlus)) .shortcut("Ctrl+N"), item(Command::Open, "Open...") .icon(lucide!(FolderOpen)) .shortcut("Ctrl+O"), submenu( Command::Recent, "Open recent", [ item(Command::Notes, "notes.md"), item(Command::Todo, "todo.md"), ], ) .icon(lucide!(Clock)), separator(), item(Command::Save, "Save") .icon(lucide!(Save)) .shortcut("Ctrl+S"), separator(), item(Command::Quit, "Quit").shortcut("Ctrl+Q"), ], ), menu( "Edit", [ item(Command::Undo, "Undo").shortcut("Ctrl+Z"), item(Command::Redo, "Redo") .shortcut("Ctrl+Shift+Z") .disabled(true), separator(), item(Command::Cut, "Cut").shortcut("Ctrl+X"), item(Command::Copy, "Copy").shortcut("Ctrl+C"), item(Command::Paste, "Paste").shortcut("Ctrl+V"), ], ), menu( "View", [ checkbox_item(Command::WordWrap, "Word wrap", true), checkbox_item(Command::LineNumbers, "Line numbers", false), separator(), group_label("Text size"), radio_item(Command::Small, "Small", false), radio_item(Command::Medium, "Medium", true), radio_item(Command::Large, "Large", false), ], ), menu( "Help", [ item(Command::Shortcuts, "Keyboard shortcuts") .icon(lucide!(Keyboard)) .shortcut("F1"), item(Command::About, "About"), ], ), ]); // Starts with the File menu open so the preview shows it. let _ = menubar.update(menubar::Event::Toggle(0)); Self { menubar, keymap: menubar::default_keymap(), menu_keymap: dropdown_menu::default_keymap(), status: String::from("Press F10 or Alt, then use the arrow keys."), } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Menubar(event) => Some(event), Message::Key(key) => self .menubar .key_event(&self.keymap, &self.menu_keymap, &key), }; let Some(output) = event.and_then(|event| self.menubar.update(event)) else { return; }; self.status = match output { Output::Activated(Command::Save) => "Saved notes.md".into(), Output::Activated(Command::Notes) => "Opened notes.md".into(), Output::Activated(Command::Todo) => "Opened todo.md".into(), Output::Activated(command) => format!("Ran {command:?}"), Output::Toggled(Command::WordWrap, on) => { format!("Word wrap {}", if on { "on" } else { "off" }) } Output::Toggled(command, on) => format!("{command:?}: {on}"), Output::Selected(size) => format!("Text size: {size:?}"), }; } pub fn view(&self) -> Element<'_, Message> { let page = container( text("# notes.md\n\nChoose a command from the menus above.") .size(text_size::SM) .style(|theme| text::Style { color: Some(Tokens::of(theme).muted_foreground), }), ) .padding(16) .height(Length::Fill); column![ menubar(&self.menubar) .keymap(self.keymap.clone()) .menu_keymap(self.menu_keymap.clone()) .on_event(Message::Menubar), page, container(text(&self.status).size(text_size::XS)).padding([6, 16]), ] .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ## API - `State::new(menus)`: Holds the menus, all closed. Item ids must be unique across every menu. - `menu(title, entries)`: One menu, built from dropdown menu entries: items with icons and shortcut hints, checkbox and radio items, submenus, labels and separators. - `.mnemonic(letter)`: The letter that opens the menu with Alt, or on its own while the bar has focus. Defaults to the first letter of the title. - `state.update(Event)`: Handles Toggle, Hover, Menu, Focus, Next, Previous, Open, OpenMenu, Close and Dismiss. Returns Output::Activated, Toggled or Selected when an item is chosen, then closes the menu and leaves the bar. - `state.open() / state.focused() / state.highlighted()`: The open menu's position, the title with the keyboard focus, and the highlighted item in the open menu. - `state.is_checked(id) / state.set_checked(id, bool) / state.set_disabled(id, bool)`: Reads and changes items in whichever menu holds them. - `state.key_event(&keymap, &menu_keymap, &key)`: Resolves a key press from keys::subscription. A closed bar claims only Focus and Alt with a mnemonic until it has focus. - `menubar(&state)`: Renders the titles, with the open menu below its title. Titles render disabled until .on_event is set. - `.on_event(f) / .width(f32)`: Maps menubar events to your message, and sets the width of each menu (224 by default). - `.keymap(keymap) / .menu_keymap(keymap)`: Replace the bar's keys and the keys inside an open menu. The open menu resolves both itself. - `menubar::default_keymap()`: The default shortcuts as a Keymap. - `action.event(&state)`: Turns a resolved Action into the Event to pass to state.update, or None when it does nothing in the bar's current state. - `trigger_style(tokens, TriggerStatus)`: The colours of a title at rest, under the pointer, open or disabled. ## Keyboard - F10 or Alt: `Focus`. Focuses the first menu title, or leaves the bar and closes its menu. - ArrowRight: `NextMenu`. Moves to the next menu, or opens the highlighted submenu inside an open menu. - ArrowLeft: `PreviousMenu`. Moves to the previous menu, or closes the open submenu inside an open menu. - ArrowDown or Enter or Space: `Open`. Opens the focused title's menu on its first item. - Escape: `Close`. Closes the open submenu or menu, or leaves the bar. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/toolbar.md - https://sam-ruff.github.io/iced-cube/docs/components/command-palette.md --- # Toolbar > A compact strip of icon buttons that moves whatever does not fit into a More menu. ```rust use iced_cube::toolbar::{ self, Output, State, button, choice, separator, spacer, toggle, toolbar, }; ``` ## Usage A toolbar measures itself as it lays out. The entries that fit stay in the strip, in order, and the rest move into a More [dropdown menu](https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md) at its end, so the same toolbar works in a wide window and on a phone. In the menu, toggles become checkbox items and choices become radio items, so a format that has overflowed still shows whether it is on. Drag the slider in the example to watch buttons move in and out. The app owns a `toolbar::State`. When `update` returns `Output::Toggled` or `Output::Selected`, the toolbar has already updated the button, so the app only changes its own model. When the model changes elsewhere, such as after undo or a Ctrl+B shortcut, call `set_on` and `set_disabled` to match. iced has no focus for a strip of buttons, so the toolbar keeps a keyboard highlight in its state and draws it as a ring. Route presses from `keys::subscription()` through `state.key_event`: Ctrl+F10 focuses the toolbar, Left and Right move through the buttons in view and then More, Enter or Space presses the highlighted one and Escape leaves. More opens its menu on the first item, which handles its own keys. A click on a button ends the keyboard focus. Each button's label shows as a [tooltip](https://sam-ruff.github.io/iced-cube/docs/components/tooltip.md) below it, with its shortcut hint. ## Examples ### Formatting toolbar Toggles, a single-choice alignment group, undo and redo. Drag the slider: whatever does not fit moves into the More menu. Ctrl+F10 focuses the toolbar for the arrow keys. ```rust use iced::keyboard::{Key, Modifiers}; use iced::widget::{column, container, text}; use iced::{Border, Element, Length, Subscription}; use iced_cube::keys::{self, Keymap}; use iced_cube::lucide; use iced_cube::primitives::slider; use iced_cube::theme::{Tokens, radius, text_size}; use iced_cube::toolbar::{ self, Action, Output, State, button, choice, separator, spacer, toggle, toolbar, }; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Tool { Bold, Italic, Underline, Strikethrough, Left, Centre, Right, Justify, Link, Undo, Redo, } const TOGGLES: [Tool; 4] = [ Tool::Bold, Tool::Italic, Tool::Underline, Tool::Strikethrough, ]; #[derive(Debug, Clone, Copy, PartialEq)] struct Format { on: [bool; 4], align: Tool, } #[derive(Debug, Clone)] pub enum Message { Toolbar(toolbar::Event), Width(f32), Key(keys::Event), } #[derive(Debug)] pub struct Example { toolbar: State, keymap: Keymap, format: Format, undo: Vec, redo: Vec, width: f32, } impl Default for Example { fn default() -> Self { let toolbar = State::new([ toggle(Tool::Bold, lucide!(Bold), "Bold", false).shortcut("Ctrl+B"), toggle(Tool::Italic, lucide!(Italic), "Italic", false).shortcut("Ctrl+I"), toggle(Tool::Underline, lucide!(Underline), "Underline", false).shortcut("Ctrl+U"), toggle( Tool::Strikethrough, lucide!(Strikethrough), "Strikethrough", false, ), separator(), choice(Tool::Left, lucide!(TextAlignStart), "Align left", true), choice( Tool::Centre, lucide!(TextAlignCenter), "Align centre", false, ), choice(Tool::Right, lucide!(TextAlignEnd), "Align right", false), choice(Tool::Justify, lucide!(TextAlignJustify), "Justify", false), separator(), button(Tool::Link, lucide!(Link), "Insert link"), spacer(), button(Tool::Undo, lucide!(Undo2), "Undo").disabled(true), button(Tool::Redo, lucide!(Redo2), "Redo").disabled(true), ]); Self { toolbar, keymap: toolbar::default_keymap(), format: Format { on: [false; 4], align: Tool::Left, }, undo: Vec::new(), redo: Vec::new(), width: 300.0, } } } impl Example { pub fn update(&mut self, message: Message) { match message { Message::Toolbar(event) => { if let Some(output) = self.toolbar.update(event) { self.apply(output); } } Message::Width(width) => self.width = width, Message::Key(key) => { if let Some(tool) = shortcut(&key) { let on = !self.toolbar.is_on(tool); self.toolbar.set_on(tool, on); self.apply(Output::Toggled(tool, on)); return; } let event = self.toolbar.key_event(&self.keymap, &key); if let Some(output) = event.and_then(|event| self.toolbar.update(event)) { self.apply(output); } } } } fn apply(&mut self, output: Output) { match output { Output::Toggled(tool, on) => { let before = self.format; if let Some(index) = TOGGLES.iter().position(|toggle| *toggle == tool) { self.format.on[index] = on; } self.record(before); } Output::Selected(align) => { let before = self.format; self.format.align = align; self.record(before); } Output::Activated(Tool::Undo) => { if let Some(format) = self.undo.pop() { self.redo.push(self.format); self.restore(format); } } Output::Activated(Tool::Redo) => { if let Some(format) = self.redo.pop() { self.undo.push(self.format); self.restore(format); } } Output::Activated(_) => {} } self.toolbar.set_disabled(Tool::Undo, self.undo.is_empty()); self.toolbar.set_disabled(Tool::Redo, self.redo.is_empty()); } fn record(&mut self, before: Format) { self.undo.push(before); self.redo.clear(); } fn restore(&mut self, format: Format) { self.format = format; for (tool, on) in TOGGLES.into_iter().zip(format.on) { self.toolbar.set_on(tool, on); } self.toolbar.set_on(format.align, true); } pub fn view(&self) -> Element<'_, Message> { let on: Vec = TOGGLES .into_iter() .zip(self.format.on) .filter(|(_, on)| *on) .map(|(tool, _)| format!("{tool:?}").to_lowercase()) .collect(); let styles = if on.is_empty() { String::from("plain") } else { on.join(", ") }; let status = format!("Text: {styles}. Alignment: {:?}.", self.format.align); let editor = container(column![ toolbar(&self.toolbar).on_event(Message::Toolbar), container(text(status).size(text_size::SM)) .padding(12) .height(72), ]) .padding(1) .max_width(self.width) .style(|theme| { let tokens = Tokens::of(theme); container::Style { border: Border { color: tokens.border, width: 1.0, radius: radius::MD.into(), }, ..container::Style::default() } }); column![ editor, slider(200.0..=680.0, self.width) .step(10.0) .label("Toolbar width") .format_value(|width| format!("{width:.0}px")) .width(Length::Fixed(280.0)) .on_change(Message::Width), ] .spacing(24) .width(Length::Fill) .align_x(iced::Alignment::Center) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } /// Ctrl+B, Ctrl+I and Ctrl+U toggle their format from anywhere. fn shortcut(key: &keys::Event) -> Option { if key.modifiers != Modifiers::COMMAND { return None; } let Key::Character(letter) = &key.key else { return None; }; match letter.as_str() { "b" => Some(Tool::Bold), "i" => Some(Tool::Italic), "u" => Some(Tool::Underline), _ => None, } } ``` ## API - `State::new(entries)`: Holds the entries and whether each toggle and choice is on. Buttons are small by default. - `button(id, icon, label) / toggle(id, icon, label, on) / choice(id, icon, label, selected)`: An action, a button that stays pressed while on, and one of a single-choice group. Adjacent choices form a group. The label is the tooltip and the overflow row. - `separator() / spacer()`: A short line between groups, and a gap that pushes the entries after it to the far end while everything fits. - `.shortcut(hint) / .disabled(bool)`: A hint added to the tooltip and the overflow row, and a disabled button. - `.with_size(Size)`: The button size: Sm (32 pixels) by default, or Md and Lg. - `state.update(Event)`: Handles presses, the overflow menu and the keyboard highlight. Returns Output::Activated, Toggled or Selected, and updates its own toggles and choices. - `state.is_on(id) / state.set_on(id, bool) / state.set_disabled(id, bool)`: Reads and changes buttons from your own state, such as after undo. - `state.visible() / state.highlighted()`: How many entries fit at the last measured width, and where the keyboard highlight is. - `toolbar::fit(entries, width, size)`: The pure measuring rule: how many entries fit before the rest move into More. - `state.key_event(&keymap, &key)`: Resolves a key press from keys::subscription. Only Focus claims a key until the toolbar has focus. - `toolbar(&state)`: Renders the entries that fit and a More menu with the rest. Buttons render disabled until .on_event is set. - `.on_event(f) / .tooltip(Option) / .more_label(text)`: Maps toolbar events to your message, moves or hides the tooltips (below by default), and names the More button. - `.menu_keymap(keymap)`: Replaces the keys the open overflow menu resolves. - `toolbar::default_keymap()`: The default shortcuts as a Keymap. ## Keyboard - Ctrl+F10: `Focus`. Highlights the first button, or leaves the toolbar. - ArrowRight: `Next`. Highlights the next button, wrapping, with More last. - ArrowLeft: `Previous`. Highlights the previous button, wrapping. - Home: `First`. Highlights the first button. - End: `Last`. Highlights the last button, or More when anything overflows. - Enter or Space: `Activate`. Presses the highlighted button, or opens the overflow menu. - Escape: `Close`. Leaves the toolbar. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/icon-button.md - https://sam-ruff.github.io/iced-cube/docs/components/menubar.md - https://sam-ruff.github.io/iced-cube/docs/components/dropdown-menu.md - https://sam-ruff.github.io/iced-cube/docs/components/tooltip.md --- # Status bar > A thin bar along the bottom of a window, fed with status from background work. ```rust use iced_cube::status_bar::{ self, Output, Section, State, Update, action, badge, progress, spinner, status_bar, text, }; ``` ## Usage Status usually comes from outside the UI thread: a sync job, a build, the language server or the editor's cursor. The status bar follows the same channel pattern as [toasts](https://sam-ruff.github.io/iced-cube/docs/components/toast.md). `status_bar::subscription()` creates a bounded channel under a stable id, so iced keeps one stream however often you ask for it, and sends `Event::Ready` with the sender first. Keep the sender and hand clones to producers, which send `Update`s. The receiver drains whatever is waiting in batches, so a burst of cursor moves costs one redraw. Every item has an id. Sending `Update::Set` with an id that is already there replaces that item in place, so a spinner can become a check mark, or a progress bar a badge, without the bar jumping about. `Update::Remove` takes an item away. Items keep the order they were first set within their section. Subscribe to `status_bar::animation(&state)` as well to turn the spinners. It asks for window frames only while a spinner is showing. The example's worker waits with `futures-timer` rather than a thread, so it also runs in the browser. On a wide window the start section sits at the left, the end section at the right and the centre section between them. Below 480 pixels the items flow onto as many lines as they need instead, so nothing is cut off on a phone. ## Examples ### Background updates A simulated worker sends git sync, build progress and cursor updates through the channel. It waits with futures-timer, so it also runs in the browser. ```rust use std::hash::{Hash, Hasher}; use std::time::Duration; use futures_timer::Delay; use iced::futures::{SinkExt, Stream, StreamExt, stream}; use iced::widget::{column, container, text}; use iced::{Element, Length, Subscription}; use iced_cube::feedback::badge::Variant; use iced_cube::lucide; use iced_cube::status_bar::{ self, Output, Section, State, Update, action, badge, progress, spinner, status_bar, }; use iced_cube::theme::text_size; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Item { Branch, Sync, Build, Problems, Cursor, Encoding, } #[derive(Debug, Clone)] pub enum Message { Status(status_bar::Event), } #[derive(Debug)] pub struct Example { status: State, worker: Option, last_pressed: Option, } impl Default for Example { fn default() -> Self { let status = State::new().with_items([ status_bar::text(Item::Branch, "main").icon(lucide!(GitBranch)), spinner(Item::Sync, "Syncing"), progress(Item::Build, 0.35) .label("Building") .section(Section::Centre), action(Item::Problems, "2 problems") .icon(lucide!(TriangleAlert)) .tooltip("Show problems") .section(Section::End), status_bar::text(Item::Cursor, "Ln 12, Col 4").section(Section::End), badge(Item::Encoding, "UTF-8", Variant::Outline).section(Section::End), ]); Self { status, worker: None, last_pressed: None, } } } impl Example { pub fn update(&mut self, message: Message) { let Message::Status(event) = message; match self.status.update(event) { // The worker holds only a sender, like a background thread would. Some(Output::Ready(sender)) => self.worker = Some(Worker { sender }), Some(Output::Pressed(item)) => self.last_pressed = Some(item), None => {} } } pub fn view(&self) -> Element<'_, Message> { let note = match self.last_pressed { Some(item) => format!("Pressed {item:?} in the status bar."), None => "A background worker updates the bar through a channel.".to_owned(), }; column![ container(text(note).size(text_size::SM)) .padding(16) .height(Length::Fill), status_bar(&self.status).on_event(Message::Status), ] .into() } pub fn subscription(&self) -> Subscription { let worker = match &self.worker { Some(worker) => Subscription::run_with(worker.clone(), Worker::run), None => Subscription::none(), }; Subscription::batch([ status_bar::subscription().map(Message::Status), status_bar::animation(&self.status).map(Message::Status), worker, ]) } } /// Simulated background work: a git sync, a build and a moving cursor. /// It waits with futures-timer, so it also runs in the browser. #[derive(Debug, Clone)] struct Worker { sender: status_bar::Sender, } impl Hash for Worker { fn hash(&self, state: &mut H) { "status-worker".hash(state); } } impl Worker { fn run(&self) -> impl Stream + use<> { let mut sender = self.sender.clone(); stream::once(async move { let mut tick: u32 = 0; loop { Delay::new(Duration::from_millis(600)).await; tick += 1; let line = 12 + tick % 40; let column = 1 + (tick * 7) % 60; let mut updates = vec![Update::Set( status_bar::text(Item::Cursor, format!("Ln {line}, Col {column}")) .section(Section::End), )]; let step = tick % 12; updates.push(Update::Set(if step < 4 { spinner(Item::Sync, "Syncing") } else { status_bar::text(Item::Sync, "Synced").icon(lucide!(Check)) })); updates.push(Update::Set(if step < 10 { progress(Item::Build, step as f32 / 10.0) .label("Building") .section(Section::Centre) } else { badge(Item::Build, "Build passed", Variant::Success).section(Section::Centre) })); for update in updates { if sender.send(update).await.is_err() { return; } } } }) .filter_map(|()| async { None:: }) } } ``` ## API - `State::new() / .with_items(items)`: An empty bar, or one starting with items, as if each had been set in turn. - `text(id, label) / action(id, label) / spinner(id, label) / progress(id, value) / badge(id, label, variant)`: Muted text, a small clickable ghost button, a spinner with a label, a short progress bar and a badge. - `.section(Section) / .icon(glyph) / .label(text) / .tooltip(text)`: Places an item at the Start (the default), Centre or End, adds an icon, a label before a progress bar, or a tooltip. - `Update::Set(item) / Update::Remove(id) / Update::Clear`: What producers send. Set replaces the item with the same id where it stands, or adds it at the end of its section. - `status_bar::subscription()`: Owns the bounded channel under a stable id. Emits Ready(sender) once, then batches of up to BATCH_SIZE (16) updates. - `status_bar::animation(&state)`: Window frames while a spinner is showing, and nothing otherwise. - `state.update(Event)`: Handles Ready, Received, Frame and Press. Returns Output::Ready(sender) once the channel is open and Output::Pressed(id) for a clickable item. - `state.apply(update) / state.items() / state.section(section) / state.item(&id)`: Applies one update directly, and reads the items back. - `status_bar(&state)`: Renders the three sections. Clickable items render disabled until .on_event is set. - `.on_event(f) / .compact_below(f32)`: Maps status bar events to your message, and sets the width below which the items flow onto several lines (480 by default). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/toast.md - https://sam-ruff.github.io/iced-cube/docs/components/progress.md - https://sam-ruff.github.io/iced-cube/docs/components/spinner.md - https://sam-ruff.github.io/iced-cube/docs/components/badge.md --- # Command palette > The app-wide launcher, opened with Ctrl+K, with recent commands and nested pages. ```rust use iced_cube::command_palette::{self, Event, Output, State, command_palette, page}; use iced_cube::navigation::command::{group, item}; ``` ## Usage The command palette puts a [command](https://sam-ruff.github.io/iced-cube/docs/components/command.md) list inside a [dialog](https://sam-ruff.github.io/iced-cube/docs/components/dialog.md) and adds what an app-wide launcher needs. The app owns a `command_palette::State`, routes presses from `keys::subscription()` through `state.key_event` so Ctrl+K or Ctrl+Shift+P opens it from anywhere, and runs whatever `update` returns as `Output::Activated`. The palette closes itself when a command runs, on Escape with an empty query, or on a click outside. Commands you run are remembered, newest first, and listed under Recent at the top while the query is empty. Typing hides that group so nothing shows twice. Choosing an item whose id names a page, such as "Change theme...", opens that page's list with its own title and placeholder instead of running anything. Backspace on an empty query, Escape or the Back button returns to the page before, and the search field keeps its focus throughout. Results can also come from background work through the command list's channel. Subscribe to `command_palette::subscription()`, keep the sender from `Output::Ready`, and when `update` returns `Output::Search(query)`, search `state.page_id()` for it and send `command::results(query, group, items)`. Results for an older query are dropped. Set `.loading(true)` while a search runs. While the search field has focus, the list keeps its own keys, then the palette's Back and toggle chords, then the dialog's, so arrows, Enter and Escape work as they do in the list on its own. ## Examples ### Pages and recent commands Ctrl+K or Ctrl+Shift+P opens it anywhere. Recent commands come first, and Change theme opens a page of its own; Backspace on an empty query goes back. ```rust use iced::widget::{column, container, text}; use iced::{Alignment, Element, Length, Subscription}; use iced_cube::command_palette::{self, Action, Event, Output, State, command_palette, page}; use iced_cube::keys::{self, Keymap}; use iced_cube::navigation::command::{group, item}; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Command { NewFile, OpenFolder, Theme, Light, Dark, System, Settings, Shortcuts, } #[derive(Debug, Clone)] pub enum Message { Palette(command_palette::Event), Key(keys::Event), } #[derive(Debug)] pub struct Example { palette: State, keymap: Keymap, last_run: Option, } impl Default for Example { fn default() -> Self { let mut palette = State::new([ group( "Files", [ item(Command::NewFile, "New file") .icon(lucide!(FilePlus)) .shortcut("Ctrl+N"), item(Command::OpenFolder, "Open folder").icon(lucide!(FolderOpen)), ], ), group( "Preferences", [ item(Command::Theme, "Change theme...") .icon(lucide!(SunMoon)) .keywords(["dark", "light", "appearance"]), item(Command::Settings, "Settings") .icon(lucide!(Settings)) .shortcut("Ctrl+,"), item(Command::Shortcuts, "Keyboard shortcuts").icon(lucide!(Keyboard)), ], ), ]) .with_page( page( Command::Theme, "Change theme", [group( "Theme", [ item(Command::Light, "Light").icon(lucide!(Sun)), item(Command::Dark, "Dark").icon(lucide!(Moon)), item(Command::System, "Match system").icon(lucide!(Monitor)), ], )], ) .placeholder("Pick a theme..."), ) .with_recent([Command::OpenFolder]) .with_visible_rows(5); let _ = palette.update(Event::Open); Self { palette, keymap: command_palette::default_keymap(), last_run: None, } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Palette(event) => Some(event), Message::Key(key) => self.palette.key_event(&self.keymap, &key), }; if let Some(Output::Activated(command)) = event.and_then(|event| self.palette.update(event)) { self.last_run = Some(command); } } pub fn view(&self) -> Element<'_, Message> { let status = match self.last_run { Some(command) => format!("Ran {command:?}"), None => "Press Ctrl+K or Ctrl+Shift+P anywhere.".to_owned(), }; let base = column![ button("Open command palette") .icon(lucide!(Search)) .variant(Variant::Outline) .on_press(Message::Palette(Event::Open)), text(status).size(14), ] .spacing(12) .align_x(Alignment::Center); command_palette( &self.palette, container(base).center(Length::Fill).padding(24), ) .placeholder("Search commands...") .max_height(320.0) .keymap(self.keymap.clone()) .on_event(Message::Palette) .into() } pub fn subscription(&self) -> Subscription { keys::subscription().map(Message::Key) } } ``` ### Async results Typing asks the app to search, and a background task streams file matches through the command list's channel. ```rust use std::hash::{Hash, Hasher}; use std::time::Duration; use futures_timer::Delay; use iced::futures::{SinkExt, Stream, stream}; use iced::widget::{column, container, text}; use iced::{Alignment, Element, Length, Subscription}; use iced_cube::command_palette::{self, Event, Output, State, command_palette}; use iced_cube::keys::{self, Keymap}; use iced_cube::navigation::command::{self, group, item, results, score}; use iced_cube::primitives::button::Variant; use iced_cube::{button, lucide}; const FILES: &[&str] = &[ "budget-2026.xlsx", "design-review.pdf", "invoice-0142.pdf", "meeting-notes.md", "release-plan.md", "report-q3.pdf", "roadmap.png", ]; #[derive(Debug, Clone)] pub enum Message { Palette(command_palette::Event<&'static str>), Key(keys::Event), SearchFinished(String), } #[derive(Debug)] pub struct Example { palette: State<&'static str>, keymap: Keymap, sender: Option>, search: Option, opened: Option<&'static str>, } impl Default for Example { fn default() -> Self { let mut palette = State::new([group( "Actions", [ item("new-file", "New file").icon(lucide!(FilePlus)), item("upload", "Upload files").icon(lucide!(FileUp)), ], )]) .with_visible_rows(6); let _ = palette.update(Event::Open); Self { palette, keymap: command_palette::default_keymap(), sender: None, search: None, opened: None, } } } impl Example { pub fn update(&mut self, message: Message) { let event = match message { Message::Palette(event) => Some(event), Message::Key(key) => self.palette.key_event(&self.keymap, &key), Message::SearchFinished(query) => { if self .search .as_ref() .is_some_and(|search| search.query == query) { self.search = None; } None } }; match event.and_then(|event| self.palette.update(event)) { Some(Output::Ready(sender)) => self.sender = Some(sender), // A new query replaces the search, which stops the old one. Some(Output::Search(query)) => { self.search = self .sender .clone() .filter(|_| !query.trim().is_empty()) .map(|sender| Search { query, sender }); } Some(Output::Activated(id)) => self.opened = Some(id), None => {} } } pub fn view(&self) -> Element<'_, Message> { let status = match self.opened { Some(id) => format!("Opened \"{id}\""), None => "Type to search files. Matches stream in from a background task.".to_owned(), }; let base = column![ button("Search files") .icon(lucide!(Search)) .variant(Variant::Outline) .on_press(Message::Palette(Event::Open)), text(status).size(14), ] .spacing(12) .align_x(Alignment::Center); command_palette( &self.palette, container(base).center(Length::Fill).padding(24), ) .title("Search") .placeholder("Search files and actions...") .loading(self.search.is_some()) .max_height(300.0) .keymap(self.keymap.clone()) .on_event(Message::Palette) .into() } pub fn subscription(&self) -> Subscription { let search = match &self.search { Some(search) => Subscription::run_with(search.clone(), Search::run), None => Subscription::none(), }; Subscription::batch([ command_palette::subscription().map(Message::Palette), keys::subscription().map(Message::Key), search, ]) } } /// A simulated remote search that only holds a sender, like a worker would. #[derive(Debug, Clone)] struct Search { query: String, sender: command::Sender<&'static str>, } impl Hash for Search { fn hash(&self, state: &mut H) { self.query.hash(state); } } impl Search { fn run(&self) -> impl Stream + use<> { let query = self.query.clone(); let mut sender = self.sender.clone(); stream::once(async move { let found: Vec<&'static str> = FILES .iter() .copied() .filter(|file| score(&query, file).is_some()) .collect(); for chunk in found.chunks(2) { Delay::new(Duration::from_millis(400)).await; let files = chunk .iter() .map(|&file| item(file, file).icon(lucide!(File))); let _ = sender.send(results(query.as_str(), "Files", files)).await; } Message::SearchFinished(query) }) } } ``` ## API - `State::new(groups)`: A closed palette listing command groups on its first page. - `.with_page(page(id, title, groups))`: A nested list. Choosing the item with the page's id opens it instead of running it. Add .placeholder(text) to the page. - `.with_recent(ids) / .with_recent_limit(n)`: Starts with recent commands, and keeps up to n of them (five by default, zero for none). - `.with_visible_rows(n)`: How many rows show at once, eight by default. - `state.update(Event)`: Handles Open, Close, Toggle, Back and the command list's own events. Returns Output::Activated when a command runs, Search(query) when the query changes, and Ready(sender) once the channel is open. - `state.is_open() / state.page() / state.page_id() / state.depth()`: Whether the palette is open, and the nested page on show. - `state.recent() / state.query() / state.command()`: The recent commands, newest first, the query and the command list inside. - `state.set_groups(groups)`: Replaces the first page's groups, for labels that follow your app's state. - `state.key_event(&keymap, &key)`: Resolves a key press from keys::subscription, so the toggle chords open the palette anywhere. - `command_palette::subscription()`: The command list's results channel, wrapped in Event::Command. - `command_palette(&state, base)`: Wraps your app in the palette's dialog. The list renders disabled until .on_event is set. - `.on_event(f) / .title(text) / .placeholder(text) / .empty(text)`: Maps palette events to your message, and sets the first page's title, the search placeholder and the empty-state text. - `.loading(bool) / .max_height(f32) / .size(dialog::Size)`: Shows that a search is running, caps the list's height (400 by default), and sets the dialog's width. - `.keymap(keymap) / .command_keymap(keymap) / .dialog_keymap(keymap)`: Replace the toggle and Back chords, the list's keys and the dialog's keys. - `command_palette::default_keymap()`: The default shortcuts as a Keymap. ## Keyboard - Ctrl+K or Ctrl+Shift+P: `Toggle`. Opens the palette from anywhere, or closes it. - Backspace: `Back`. Returns to the page before, while the query is empty. These are defaults; change them with a keymap (https://sam-ruff.github.io/iced-cube/docs/keyboard.md). ## See also - https://sam-ruff.github.io/iced-cube/docs/components/command.md - https://sam-ruff.github.io/iced-cube/docs/components/dialog.md - https://sam-ruff.github.io/iced-cube/docs/components/menubar.md