oriel_iced/lib.rs
1//! `oriel-iced` — a virtualized, batteries-included data grid for
2//! [iced](https://iced.rs) 0.14.
3//!
4//! Sort, filter, live transactional ingest with tail-follow, selection and
5//! keyboard navigation, resizable/auto-sizing columns, footers with
6//! aggregates, row animations, themes (CSS-verified ports of ag-grid
7//! Quartz/Alpine/Balham, Material, Spreadsheet, Bootstrap), and smooth
8//! scrolling over hundreds of thousands of rows — only the visible slice is
9//! ever materialized.
10//!
11//! The heavy thinking lives in [`oriel_core`] (headless: the data seam, the
12//! column model, virtualization math, view state); this crate renders it
13//! with iced and owns all pixel concerns. Run the full tour with
14//! `cargo run --example showcase --release`.
15//!
16//! # Quickstart
17//!
18//! A complete application: 100 000 rows, sortable/filterable/selectable,
19//! with resizable auto-sizing columns — the grid's defaults.
20//!
21//! ```no_run
22//! use iced::{Element, Task};
23//! use oriel_core::columns::ColumnId;
24//! use oriel_core::data::VecStore;
25//! use oriel_core::query::FilterValue;
26//! use oriel_iced::column::TextColumn;
27//! use oriel_iced::grid::{self, BoxedColumn, VirtualGrid};
28//!
29//! #[derive(Clone, Debug)]
30//! struct Person {
31//! id: u64,
32//! name: String,
33//! }
34//!
35//! const ID_COL: ColumnId = ColumnId(1);
36//! const NAME_COL: ColumnId = ColumnId(2);
37//!
38//! type Store = VecStore<Person, u64, fn(&Person) -> u64>;
39//!
40//! struct App {
41//! grid: VirtualGrid<Store>,
42//! }
43//!
44//! #[derive(Debug, Clone)]
45//! enum Msg {
46//! Grid(grid::Message<Person>),
47//! }
48//!
49//! fn boot() -> (App, Task<Msg>) {
50//! fn key(p: &Person) -> u64 {
51//! p.id
52//! }
53//! let rows = (0..100_000u64)
54//! .map(|id| Person { id, name: format!("Person {id}") })
55//! .collect();
56//! // The field resolver makes header-click sorting and filters work.
57//! let store = VecStore::new(rows, key as fn(&Person) -> u64).with_fields(|p, col| {
58//! match col {
59//! ID_COL => Some(FilterValue::Int(p.id as i64)),
60//! NAME_COL => Some(FilterValue::Text(p.name.clone())),
61//! _ => None,
62//! }
63//! });
64//! let columns: Vec<BoxedColumn<Person>> = vec![
65//! Box::new(TextColumn::new(ID_COL, "#", 70.0, |p: &Person| p.id.to_string()).numeric()),
66//! Box::new(TextColumn::new(NAME_COL, "Name", 260.0, |p: &Person| p.name.clone())),
67//! ];
68//! let (grid, init) = VirtualGrid::new(store, columns);
69//! (App { grid }, init.map(Msg::Grid))
70//! }
71//!
72//! fn update(app: &mut App, msg: Msg) -> Task<Msg> {
73//! match msg {
74//! Msg::Grid(m) => app.grid.update(m).map(Msg::Grid),
75//! }
76//! }
77//!
78//! fn view(app: &App) -> Element<'_, Msg> {
79//! app.grid.view().map(Msg::Grid)
80//! }
81//!
82//! fn main() -> iced::Result {
83//! iced::application(boot, update, view).run()
84//! }
85//! ```
86//!
87//! # The wiring contract
88//!
89//! The grid is a value in your model, not a widget with hidden state. Three
90//! obligations, all visible in the quickstart:
91//!
92//! 1. **Forward its messages**: everything the grid emits arrives as your
93//! wrapper around [`grid::Message`]; hand it to
94//! [`VirtualGrid::update`](grid::VirtualGrid::update).
95//! 2. **Run the returned tasks**: `update` — and every imperative method
96//! ([`transact`](grid::VirtualGrid::transact),
97//! [`set_spec`](grid::VirtualGrid::set_spec),
98//! [`set_follow`](grid::VirtualGrid::set_follow), …) — returns an
99//! [`iced::Task`]; return it from your `update` (batch and `map` as
100//! needed). Dropping a task drops a fetch or a scroll command.
101//! 3. **Map the view**: [`VirtualGrid::view`](grid::VirtualGrid::view)
102//! renders the whole grid (header, body, footer, scrollbars).
103//!
104//! # Where do I…?
105//!
106//! | I want to… | Reach for |
107//! |---|---|
108//! | feed live data (append/update/remove) | [`grid::VirtualGrid::transact`] |
109//! | pin the view to the newest rows | [`grid::VirtualGrid::set_follow`] (auto-freezes when the user scrolls up) |
110//! | sort / filter programmatically | [`grid::VirtualGrid::set_spec`] + [`grid::VirtualGrid::spec`] |
111//! | wire keyboard navigation | [`grid::VirtualGrid::focus_next`] / [`focus_prev`](grid::VirtualGrid::focus_prev) / [`page_up`](grid::VirtualGrid::page_up) / [`page_down`](grid::VirtualGrid::page_down) — bind them to any keys |
112//! | read / drive selection | [`grid::VirtualGrid::selection`] / [`selection_mut`](grid::VirtualGrid::selection_mut) |
113//! | persist and restore the scroll position | [`grid::VirtualGrid::scroll_anchor`] / [`scroll_to_anchor`](grid::VirtualGrid::scroll_to_anchor) (row-space: stable under churn) |
114//! | restyle rows/cells/bands | the `set_*_style` hooks on [`grid::VirtualGrid`], wrapping the public `default_*` fns |
115//! | apply a whole look | [`theme::GridTheme`] presets ([`apply`](theme::GridTheme::apply)) |
116//! | render a custom cell (chips, pills, sparklines) | implement [`oriel_core::columns::Column`] — see [`column`](mod@column) for the provided renderers |
117//! | put my data behind an async boundary | implement [`oriel_core::data::DataSource`] |
118//! | fit columns to content | grip double-click does it; [`grid::VirtualGrid::auto_size_all`] for everything |
119//!
120//! # Keyboard
121//!
122//! Navigation is exposed as **actions, not bindings** — bind any keys (or
123//! gamepad, or vim chords) to [`focus_next`](grid::VirtualGrid::focus_next),
124//! [`focus_prev`](grid::VirtualGrid::focus_prev),
125//! [`page_up`](grid::VirtualGrid::page_up) /
126//! [`page_down`](grid::VirtualGrid::page_down), and
127//! [`set_follow`](grid::VirtualGrid::set_follow) /
128//! [`scroll_to_row`](grid::VirtualGrid::scroll_to_row) for End/Home:
129//!
130//! ```no_run
131//! # use iced::Subscription;
132//! # use oriel_core::data::VecStore;
133//! # use oriel_iced::grid::{self, VirtualGrid};
134//! # #[derive(Clone, Debug)] struct Row { id: u64 }
135//! # type Store = VecStore<Row, u64, fn(&Row) -> u64>;
136//! # struct App { grid: VirtualGrid<Store> }
137//! # #[derive(Debug, Clone)] enum Msg { Grid(grid::Message<Row>), Key(iced::keyboard::Event) }
138//! fn subscription(_app: &App) -> Subscription<Msg> {
139//! iced::keyboard::listen().map(Msg::Key)
140//! }
141//!
142//! fn update(app: &mut App, msg: Msg) -> iced::Task<Msg> {
143//! use iced::keyboard::key::{Key, Named};
144//! match msg {
145//! Msg::Grid(m) => app.grid.update(m).map(Msg::Grid),
146//! Msg::Key(iced::keyboard::Event::KeyPressed { key, .. }) => match key {
147//! Key::Named(Named::ArrowDown) => app.grid.focus_next().map(Msg::Grid),
148//! Key::Named(Named::ArrowUp) => app.grid.focus_prev().map(Msg::Grid),
149//! Key::Named(Named::PageDown) => app.grid.page_down().map(Msg::Grid),
150//! Key::Named(Named::PageUp) => app.grid.page_up().map(Msg::Grid),
151//! _ => iced::Task::none(),
152//! },
153//! Msg::Key(_) => iced::Task::none(),
154//! }
155//! }
156//! ```
157//!
158//! Gotcha worth knowing: `iced::keyboard::listen` only delivers events no
159//! widget consumed, and a focused `text_input` consumes *some* keys
160//! (Home/End/Delete/arrows-left-right) while letting others through
161//! (arrows-up-down, PageUp/Down) — scope your bindings if the two fight.
162//!
163//! # Persisting a session
164//!
165//! Everything worth restoring is exposed as a holdable value with a
166//! read/write pair:
167//!
168//! | State | read | restore |
169//! |---|---|---|
170//! | column widths | [`column_layout`](grid::VirtualGrid::column_layout) (`placements()`) | [`column_layout_mut`](grid::VirtualGrid::column_layout_mut) + `set_width` (doc example there) |
171//! | sort + filter | [`spec`](grid::VirtualGrid::spec) (serde-ready with oriel-core's `serde` feature) | [`set_spec`](grid::VirtualGrid::set_spec) |
172//! | scroll position | [`scroll_anchor`](grid::VirtualGrid::scroll_anchor) (row-space — stable under churn) | [`scroll_to_anchor`](grid::VirtualGrid::scroll_to_anchor) |
173//! | selection | [`selection`](grid::VirtualGrid::selection) (iterate the `RowId`s) | [`selection_mut`](grid::VirtualGrid::selection_mut) |
174//!
175//! # Testing your integration
176//!
177//! The grid is a plain value — no window or renderer needed for state-level
178//! tests: drive [`update`](grid::VirtualGrid::update) with synthesized
179//! messages and assert on the exposed state.
180//!
181//! ```
182//! # use oriel_core::{columns::ColumnId, data::VecStore};
183//! # use oriel_iced::grid::{BoxedColumn, Message, VirtualGrid};
184//! # use oriel_iced::column::TextColumn;
185//! # #[derive(Clone, Debug)] struct Row { id: u64 }
186//! # fn key(r: &Row) -> u64 { r.id }
187//! # let store = VecStore::new((0..10).map(|id| Row { id }).collect(), key as fn(&Row) -> u64);
188//! # let columns: Vec<BoxedColumn<Row>> =
189//! # vec![Box::new(TextColumn::new(ColumnId(1), "#", 80.0, |r: &Row| r.id.to_string()))];
190//! # let (mut grid, _init) = VirtualGrid::new(store, columns);
191//! # let _ = grid.transact(|_| {}); // fill the fetch window synchronously
192//! let _ = grid.update(Message::RowPressed { index: 3 });
193//! assert!(grid.selection().contains(&3));
194//! let _ = grid.focus_next();
195//! assert!(grid.selection().contains(&4)); // nav single-selects as it moves
196//! ```
197//!
198//! For full interaction tests (clicks, wheel, lifecycle) the repository's
199//! test suite drives the real `iced_runtime::UserInterface` with the
200//! tiny-skia renderer — headless, no GPU; its `tests/common/mod.rs` is the
201//! copyable harness.
202//!
203//! # Scale
204//!
205//! Materialized widgets are a function of the viewport, never the row
206//! count. The shipped grid addresses scroll space in flat `f32` pixels
207//! (right for resident data): sub-pixel-exact while
208//! `rows × row_height < 2^24 px` — ~670 000 rows at the default height,
209//! ~350 000 at the tallest theme. Beyond that envelope sits the core's
210//! row-space anchor path (exact to `usize::MAX`), held open for an
211//! out-of-core integration; a standing core test guards the boundary.
212//!
213//! # Modules
214//!
215//! - [`grid`] — [`VirtualGrid`](grid::VirtualGrid), the widget itself: its
216//! messages, knobs, hooks, and public style defaults.
217//! - [`column`](mod@column) — provided column renderers
218//! ([`TextColumn`](column::TextColumn), [`BarColumn`](column::BarColumn))
219//! and the header font.
220//! - [`theme`] — [`GridTheme`](theme::GridTheme): ready-made looks with
221//! documented provenance.
222//! - [`arrow`], [`measure`], [`resize`], [`scrollbar`], [`slide`] — the
223//! grid's hand-rolled widgets (sort arrow, auto-size measurer, resize
224//! grips, min-thumb scrollbar, row animations). Public for reuse; most
225//! integrators never touch them.
226//!
227//! # Versioning
228//!
229//! iced 0.14 is pre-1.0 and its `Widget` API moves between minor versions;
230//! this crate pins the version it is verified against. Interaction
231//! behaviors that depend on upstream internals are guarded by canary tests
232//! in the repository, re-checked on every bump.
233
234// Machinery, not habit: the public surface is documented
235// to docs.rs standard — keep it that way by construction.
236#![deny(missing_docs)]
237#![deny(rustdoc::broken_intra_doc_links)]
238
239pub mod arrow;
240pub mod column;
241pub mod grid;
242pub mod measure;
243pub mod resize;
244pub mod scrollbar;
245pub mod slide;
246pub mod theme;