diff --git a/AGENTS.md b/AGENTS.md index 68b2164..65b4427 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,12 @@ src/ ``` **Components** (`components.rs`): `card`, `new_tile`, `icon_button`, -`text_button`, `link`, `list_row`, `text_input`, `search_field`, `toggle`, -`modal`, `page_header`, `section_header`. +`text_button`, `link`, `badge`, `menu_button`, `menu_item`, `list_row`, +`text_input`, `search_field`, `secret_input`, `toggle`, `modal`, +`page_header`, `section_header`, `nav_item`, `checkbox`, `segmented`, +`select`, `select_option`, `banner`, `collapsing`, `cad_tool_button`, +`data_table` (+ `SortState`, `sortable_header`), `toast_overlay` +(+ `ToastStack`). ## Rules — keep these true @@ -85,4 +89,4 @@ out of these primitives. - No keyboard focus rings / `widget_info` accessibility yet — components are painted rectangles, not screen-reader-navigable. Highest-value next step. -- Missing components a real app will want: tooltip, tabs, toast, checkbox. +- Missing components a real app will want: tooltip, tabs. diff --git a/Cargo.toml b/Cargo.toml index efde819..2f3ef56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ categories = ["gui", "rendering::gui"] [dependencies] egui = "0.29" egui-phosphor = { version = "0.7", default-features = false, features = ["regular"] } +egui_extras = "0.29" [dev-dependencies] eframe = "0.29" diff --git a/README.md b/README.md index a5409e4..385f72a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,9 @@ All live in `tokito_ui::components` (aliased `c` above). Each takes | `select_option` | `select_option(ui, t, label, selected) -> bool` | One option row inside a `select` popup. | | `banner` | `banner(ui, t, kind, glyph, title, body) -> Response` | A status callout — `BannerKind::Success` / `Danger` / `Warning` / `Info`. | | `collapsing` | `collapsing(ui, t, id_source, label, \|ui\| …)` | A collapsible "Advanced options" disclosure section. | +| `cad_tool_button` | `cad_tool_button(ui, t, glyph, side, selected, tooltip) -> Response` | A square, toggleable CAD tool-rail button — accent border + soft fill when selected. | +| `data_table` + `sortable_header` | `data_table(ui, t, id, headers, cols, &mut SortState, n, h, \|row, i\| …)` | A scrollable [`egui_extras::TableBuilder`] table with click-to-sort column headers. | +| `toast_overlay` + `ToastStack` | `toast_overlay(ctx, t, &mut ToastStack)` | Transient bottom-right notifications. `ToastStack` is the owned queue; push from anywhere, paint once per frame. | ### Icons diff --git a/src/components.rs b/src/components.rs index 69e3449..7cea3aa 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1047,3 +1047,312 @@ fn paint_dashed_rect( fn lighten(c: Color32, amount: f32) -> Color32 { lerp_color(c, Color32::WHITE, amount) } + +// --------------------------------------------------------------------------- +// cad_tool_button +// --------------------------------------------------------------------------- + +/// A square, toggleable CAD-tool-rail button. +/// +/// Used for the left-side tool rail in a schematic / PCB editor (select, +/// wire, label, bus, etc.). `glyph` is a Phosphor constant; `side` is the +/// width and height; `selected` paints the active state (accent border + +/// soft accent fill); `tooltip` shows on hover. +/// +/// Hover eases an underlay fill in; the icon ink is `accent` when selected, +/// `text` otherwise. +pub fn cad_tool_button( + ui: &mut Ui, + t: &Tokens, + glyph: &str, + side: f32, + selected: bool, + tooltip: &str, +) -> Response { + let (rect, mut response) = ui.allocate_exact_size(Vec2::splat(side), Sense::click()); + + let factor = hover_t(ui, response.id, response.hovered()); + let painter = ui.painter(); + + let (fill, stroke) = if selected { + let stroke = Stroke::new(1.2, t.accent); + let fill = lerp_color(t.accent_soft, lighten(t.accent_soft, 0.10), factor); + (fill, stroke) + } else { + let fill = lerp_color(t.card, t.card_hover, factor); + let stroke_color = lerp_color(t.border, t.border_strong, factor); + let stroke = Stroke::new(1.0, stroke_color); + (fill, stroke) + }; + + painter.rect_filled(rect, t.rounding_sm(), fill); + painter.rect_stroke(rect, t.rounding_sm(), stroke); + + let ink = if selected { + t.accent + } else { + lerp_color(t.text_2, t.text, factor) + }; + let glyph_size = (side * 0.5).clamp(14.0, 24.0); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + glyph, + icons::font(glyph_size), + ink, + ); + + if !tooltip.is_empty() { + response = response.on_hover_text(tooltip); + } + response +} + +// --------------------------------------------------------------------------- +// table +// --------------------------------------------------------------------------- + +/// Sort direction for a [`SortState`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SortDir { + /// Unsorted — the natural row order. + #[default] + None, + /// Ascending — `A..Z`, `0..9`, oldest-first. + Asc, + /// Descending — `Z..A`, `9..0`, newest-first. + Desc, +} + +impl SortDir { + fn arrow(self) -> &'static str { + match self { + SortDir::None => "", + SortDir::Asc => " ▲", + SortDir::Desc => " ▼", + } + } +} + +/// Which column is currently the sort key and in what direction. +/// +/// Stored by the consumer (e.g. one [`SortState`] per visible table). Drive +/// from [`sortable_header`] click responses, then read in your row-building +/// loop to decide order. +#[derive(Debug, Clone, Copy, Default)] +pub struct SortState { + pub column: usize, + pub dir: SortDir, +} + +impl SortState { + /// Click handler for header column `col`. Cycles + /// `None → Asc → Desc → None`, or resets to `Asc` when switching columns. + pub fn toggle(&mut self, col: usize) { + if self.column == col { + self.dir = match self.dir { + SortDir::None => SortDir::Asc, + SortDir::Asc => SortDir::Desc, + SortDir::Desc => SortDir::None, + }; + } else { + self.column = col; + self.dir = SortDir::Asc; + } + } +} + +/// A clickable column-header label that updates a [`SortState`]. +/// +/// Shows an arrow suffix when this column is the active sort key. Returns +/// `true` on the frame the label is clicked (caller uses this only if it +/// wants side-effects beyond `state.toggle(col)`). +pub fn sortable_header( + ui: &mut Ui, + t: &Tokens, + label: &str, + col: usize, + state: &mut SortState, +) -> bool { + let active = state.column == col && state.dir != SortDir::None; + let arrow = if active { state.dir.arrow() } else { "" }; + let text = RichText::new(format!("{label}{arrow}")) + .strong() + .color(if active { t.text } else { t.text_2 }); + + let resp = ui.add(egui::Label::new(text).sense(Sense::click())); + let clicked = resp.clicked(); + if clicked { + state.toggle(col); + } + clicked +} + +/// A scrollable table with [`sortable_header`]-driven sortable columns. +/// +/// `id_source` salts the inner scroll area's id so multiple tables on one +/// screen don't collide. `headers` is one label per column. `row_height` is +/// the per-row height for [`egui_extras::TableBuilder`]. `cols` describes the +/// column widths — pass [`egui_extras::Column`] values. +/// +/// The `build_row` closure paints one cell per column for a given row index. +/// Callers usually pre-sort their data by `state` *before* calling this, then +/// index into the sorted vector inside `build_row`. +pub fn data_table( + ui: &mut Ui, + t: &Tokens, + id_source: impl Hash, + headers: &[&str], + cols: Vec, + state: &mut SortState, + row_count: usize, + row_height: f32, + mut build_row: F, +) where + F: FnMut(&mut egui_extras::TableRow<'_, '_>, usize), +{ + let id = ui.make_persistent_id(id_source); + let mut builder = egui_extras::TableBuilder::new(ui) + .id_salt(id) + .striped(true) + .resizable(false) + .cell_layout(Layout::left_to_right(Align::Center)); + for c in cols { + builder = builder.column(c); + } + builder + .header(22.0, |mut header| { + for (col, label) in headers.iter().enumerate() { + header.col(|ui| { + sortable_header(ui, t, label, col, state); + }); + } + }) + .body(|body| { + body.rows(row_height, row_count, |row| { + let idx = row.index(); + let mut row = row; + build_row(&mut row, idx); + }); + }); +} + +// --------------------------------------------------------------------------- +// toast +// --------------------------------------------------------------------------- + +/// Visual + semantic kind of a [`Toast`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ToastKind { + #[default] + Info, + Success, + Warning, + Error, +} + +/// One transient notification message. +#[derive(Debug, Clone)] +pub struct Toast { + /// What the user sees. + pub message: String, + /// Visual class. + pub kind: ToastKind, + /// When this toast expires. Defaults to ~4 s after `push`. + pub until: std::time::Instant, +} + +/// A queue of [`Toast`]s, drained by [`toast_overlay`]. +/// +/// Holders own this struct in their app state and call `push` from anywhere +/// in the update loop; once per frame they hand it to [`toast_overlay`] to +/// paint. Expired entries are pruned automatically. +#[derive(Debug, Default, Clone)] +pub struct ToastStack { + items: Vec, +} + +impl ToastStack { + /// Default visible time per toast — currently 4 seconds. + pub const DEFAULT_TTL: std::time::Duration = std::time::Duration::from_secs(4); + + /// Push a new toast with `kind` and the default 4 s TTL. + pub fn push(&mut self, message: impl Into, kind: ToastKind) { + self.items.push(Toast { + message: message.into(), + kind, + until: std::time::Instant::now() + Self::DEFAULT_TTL, + }); + } + + /// Push an [`ToastKind::Info`] toast. + pub fn push_info(&mut self, message: impl Into) { + self.push(message, ToastKind::Info); + } + + /// Push a [`ToastKind::Success`] toast. + pub fn push_success(&mut self, message: impl Into) { + self.push(message, ToastKind::Success); + } + + /// Push a [`ToastKind::Warning`] toast. + pub fn push_warning(&mut self, message: impl Into) { + self.push(message, ToastKind::Warning); + } + + /// Push a [`ToastKind::Error`] toast. + pub fn push_error(&mut self, message: impl Into) { + self.push(message, ToastKind::Error); + } + + /// True when there are no live (non-expired) toasts. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + fn prune(&mut self) { + let now = std::time::Instant::now(); + self.items.retain(|t| t.until > now); + } +} + +/// Paint any live toasts anchored to the bottom-right of the egui screen. +/// +/// Call once per frame. The stack is mutated in place: expired toasts are +/// pruned before painting, and egui is asked to repaint while a toast is +/// still live so the auto-dismissal happens on time. +pub fn toast_overlay(ctx: &egui::Context, t: &Tokens, stack: &mut ToastStack) { + stack.prune(); + if stack.is_empty() { + return; + } + + // Repaint while we still have live toasts so they vanish on time even + // when nothing else moves on screen. + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + + egui::Area::new(egui::Id::new("tokito_ui_toasts")) + .anchor(egui::Align2::RIGHT_BOTTOM, [-16.0, -16.0]) + .show(ctx, |ui| { + ui.vertical(|ui| { + // Newest-first: most-recent push appears on top. + for toast in stack.items.iter().rev() { + let (border, ink) = match toast.kind { + ToastKind::Info => (t.accent, t.text), + ToastKind::Success => (t.success, t.success), + ToastKind::Warning => (t.warning, t.warning), + ToastKind::Error => (t.danger, t.danger), + }; + egui::Frame::popup(ui.style()) + .fill(t.card) + .stroke(Stroke::new(1.0, border)) + .rounding(t.rounding_md()) + .inner_margin(egui::Margin::symmetric(12.0, 8.0)) + .show(ui, |ui| { + ui.label(RichText::new(&toast.message).color(ink)); + }); + ui.add_space(t.space_2); + } + }); + }); +}