diff --git a/README.md b/README.md index 4db4713..bdbb1a4 100644 --- a/README.md +++ b/README.md @@ -127,15 +127,30 @@ inputs.vidya.url = "git+https://tangled.org/nandi.uk/vidya"; | `apply` / `apply_dark` / `apply_light` | Install on `egui::Context` (+ symbol font) | | `install_symbol_font` | Fallback glyphs for arrows / disclosure triangles / bullets / quotes | | `emoji_icon` / `paint_emoji_in` / `has_emoji_icon` | Full Twemoji color set by codepoint | -| `Icon` / `icon` / `paint_icon_in` | Named shortcuts + stroke Plus | +| `Icon` / `icon` / `paint_icon_in` | Named shortcuts + stroke Plus / Copy | +| `icon_button` | Square tool button with a stroke/emoji icon | | `reaction_chip` | Themed count chip with color emoji | | `primary_button` / `button` / `destructive_button` | Styled actions | | `checkbox` | Accent-filled checkbox with drawn checkmark | | `status_dot` | Live/offline circle (drawn — no Unicode tofu on Android) | -| `text_field_singleline` / `text_field_multiline` | Text inputs with field padding | +| `text_field_singleline` / `text_field_multiline` | Text inputs with field padding (fill parent width) | | `title` / `title_2` / `body` / `dim_label` | Text roles | | `Theme::header_frame` / `card_frame` / `page_frame` | Layout chrome | | `Theme::text_edit_margin` | Inner field padding (12×8 default) | +| **Layout composition** | Prefer these over raw `set_max_width` / `Layout` plumbing | +| `fit_width` / `fill_width` | Pin children to residual width (no edge overflow) | +| `vstack` | Non-justified vertical stack (no giant gaps in tall parents) | +| `card` | Fill-width themed card + `vstack` content | +| `compact_card` | Fixed-width card that **hugs content** (gauge / anomaly tiles) | +| `pack` | Wrap compact tiles without stretching leftover horizontal space | +| `hflow` | Wrapping horizontal row (toolbars / chips) | +| `lead_trail` | Leading field + trailing actions without clipping the actions | +| `two_col` / `side_by_side` | Responsive two-column / stack (+ pure breakpoint policy) | +| `page_body` / `central_page` | Scrollable page content capped to the app view | +| `inset_row` | Soft inset row capped to parent width | +| `metric_bps` / `metric_rate` | Fixed-width monospace rate strings (no staircase columns) | +| `metric_cell` / `table_metric` / `table_text` | Right-edge metric cells + flex text for tables | +| `data_table` | Striped grid: flex columns + fixed metric columns | | `reserve_system_chrome` / `system_chrome` | Android status + nav safe areas | | `top_header` | Header panel with system chrome already reserved | diff --git a/src/chrome.rs b/src/chrome.rs index c6cff4b..f4d4a81 100644 --- a/src/chrome.rs +++ b/src/chrome.rs @@ -7,11 +7,18 @@ //! Call [`reserve_system_chrome`] once at the start of every frame (before any //! other `TopBottomPanel` / `SidePanel` / `CentralPanel`), or use //! [`top_header`] which does that for you. +//! +//! Prefer injecting measured insets via [`set_system_chrome`] (from +//! `AndroidApp::content_rect` or WindowInsets) so reserves match the device — +//! hardcoded fallbacks are intentionally tight for modern gesture-nav phones. -use egui::{Context, Frame, Margin, Sense, Ui}; +use egui::{Context, Frame, Id, Margin, Sense, Ui}; use crate::Theme; +/// Temp-data id for app-supplied measured insets (see [`set_system_chrome`]). +const SYSTEM_CHROME_ID: &str = "vidya.system_chrome"; + /// Insets for system status / navigation chrome on edge-to-edge surfaces. #[derive(Debug, Clone, Copy, PartialEq)] pub struct SystemChrome { @@ -32,24 +39,50 @@ impl SystemChrome { } } +/// Inject measured system insets for this frame (egui points). +/// +/// Call once per frame **before** [`reserve_system_chrome`]. When set, +/// [`system_chrome`] uses these values instead of the platform fallbacks +/// (IME expansion still applies on top of `bottom`). +pub fn set_system_chrome(ctx: &Context, chrome: SystemChrome) { + ctx.data_mut(|d| d.insert_temp(Id::new(SYSTEM_CHROME_ID), chrome)); +} + /// Platform defaults for edge-to-edge drawing. /// -/// Values match common gesture-nav phones (≈24–36 dp status, ≈48 dp nav). +/// Fallback values are **tight** for modern gesture-nav phones (≈24–28 dp +/// status, ≈16–24 dp gesture handle). Prefer [`set_system_chrome`] with +/// measured `content_rect` / WindowInsets when available. +/// /// When a text field holds focus (`Context::wants_keyboard_input`), the bottom /// inset grows so bottom bars / compose fields sit **above** the soft keyboard /// (NativeActivity rarely resizes the GL surface for IME). pub fn system_chrome(ctx: &Context) -> SystemChrome { #[cfg(target_os = "android")] { - let top = 36.0; - // Gesture / 3-button nav when the keyboard is hidden. - let mut bottom = 48.0; + let measured = ctx.data(|d| d.get_temp::(Id::new(SYSTEM_CHROME_ID))); + // Fallbacks when the app does not inject measured insets. Top must + // clear the status bar under edge-to-edge (never “shy” into the clock). + const TOP_FALLBACK: f32 = 36.0; + const BOTTOM_FALLBACK: f32 = 20.0; + let top = match measured { + // Reject zero/near-zero injected tops — that was the regression: + // content_rect top=0 overrode the fallback and drew under the clock. + Some(c) if c.top >= 8.0 => c.top, + Some(c) => c.top.max(TOP_FALLBACK), + None => TOP_FALLBACK, + }; + let mut bottom = match measured { + Some(c) => c.bottom.max(0.0), + None => BOTTOM_FALLBACK, + }; if ctx.wants_keyboard_input() { // Soft keyboards are typically ~35–45% of portrait height. Pad that // much under all UI so TopBottomPanel compose / tabs clear the IME. // (Seen on Pixel: ime inset ≈ 988px on a 2400px-tall display.) let h = ctx.screen_rect().height(); - bottom = (h * 0.40).clamp(240.0, h * 0.52); + let ime = (h * 0.40).clamp(240.0, h * 0.52); + bottom = bottom.max(ime); // Keep animating a few frames while the keyboard slides in/out. ctx.request_repaint(); } @@ -76,13 +109,20 @@ pub fn reserve_system_chrome(ctx: &Context, theme: &Theme) { return; } - let fill = theme.palette.headerbar_bg; - let band = Frame::new().fill(fill).inner_margin(Margin::ZERO); + // Top band matches the header so status-bar area reads as continuous chrome + // under the clock (not a hole the title can climb into). + // Bottom band matches window bg so gesture/nav clearance stays subtle. + let top_band = Frame::new() + .fill(theme.palette.headerbar_bg) + .inner_margin(Margin::ZERO); + let bottom_band = Frame::new() + .fill(theme.palette.window_bg) + .inner_margin(Margin::ZERO); if chrome.top > 0.0 { egui::TopBottomPanel::top("vidya_system_chrome_top") .exact_height(chrome.top) - .frame(band) + .frame(top_band) .show_separator_line(false) .show(ctx, |_ui| {}); } @@ -90,7 +130,7 @@ pub fn reserve_system_chrome(ctx: &Context, theme: &Theme) { if chrome.bottom > 0.0 { egui::TopBottomPanel::bottom("vidya_system_chrome_bottom") .exact_height(chrome.bottom) - .frame(band) + .frame(bottom_band) .show_separator_line(false) .show(ctx, |ui| { // Absorb input so gestures aren't stolen by widgets behind the band. diff --git a/src/icons.rs b/src/icons.rs index be72f8f..07a25aa 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -69,6 +69,8 @@ pub enum Icon { Frown, /// Drawn stroke — not a Twemoji glyph. Plus, + /// Drawn stroke — overlapping pages (copy / duplicate). + Copy, } impl Icon { @@ -89,8 +91,14 @@ impl Icon { Icon::Frown, Icon::ThumbsDown, Icon::Plus, + Icon::Copy, ]; + /// True when this icon is stroke-drawn (not a Twemoji bitmap). + pub fn is_stroke(self) -> bool { + matches!(self, Icon::Plus | Icon::Copy) + } + pub fn emoji(self) -> &'static str { match self { Icon::ThumbsUp => "👍", @@ -100,6 +108,7 @@ impl Icon { Icon::Surprised => "😮", Icon::Frown => "😢", Icon::Plus => "+", + Icon::Copy => "⧉", } } } @@ -248,11 +257,11 @@ pub fn icon(ui: &mut Ui, theme: &Theme, icon: Icon, size: f32) -> Response { icon_colored(ui, theme.palette.text, icon, size) } -/// Like [`icon`]; `color` only affects [`Icon::Plus`]. +/// Like [`icon`]; `color` only affects stroke icons ([`Icon::Plus`], [`Icon::Copy`]). pub fn icon_colored(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { let size = size.max(8.0); match icon { - Icon::Plus => paint_plus(ui, color, size), + Icon::Plus | Icon::Copy => paint_stroke_icon(ui, color, icon, size), other => { let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); paint_emoji_in(ui, rect, other.emoji(), color); @@ -281,24 +290,39 @@ pub fn emoji_icon_colored( response } -/// Draw [`Icon::Plus`] into `rect` (emoji icons need [`paint_emoji_in`] / [`paint_icon_in`]). +/// Draw a stroke icon ([`Icon::Plus`], [`Icon::Copy`]) into `rect`. +/// Emoji icons need [`paint_emoji_in`] / [`paint_icon_in`]. pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color32) { - if !matches!(icon, Icon::Plus) { - return; - } let s = rect.width().min(rect.height()); let c = rect.center(); - let arm = s * 0.32; let w = (s * 0.12).clamp(1.5, 2.5); let stroke = Stroke::new(w, color); - painter.line_segment([pos2(c.x - arm, c.y), pos2(c.x + arm, c.y)], stroke); - painter.line_segment([pos2(c.x, c.y - arm), pos2(c.x, c.y + arm)], stroke); + match icon { + Icon::Plus => { + let arm = s * 0.32; + painter.line_segment([pos2(c.x - arm, c.y), pos2(c.x + arm, c.y)], stroke); + painter.line_segment([pos2(c.x, c.y - arm), pos2(c.x, c.y + arm)], stroke); + } + Icon::Copy => { + // Two overlapping pages — classic “copy” glyph (stroke only). + let page_w = s * 0.38; + let page_h = s * 0.46; + let r = (s * 0.08).clamp(0.5, 2.5); + let dx = s * 0.11; + let dy = s * 0.11; + let back = Rect::from_center_size(pos2(c.x - dx, c.y - dy), Vec2::new(page_w, page_h)); + let front = Rect::from_center_size(pos2(c.x + dx, c.y + dy), Vec2::new(page_w, page_h)); + painter.rect_stroke(back, r, stroke, egui::StrokeKind::Inside); + painter.rect_stroke(front, r, stroke, egui::StrokeKind::Inside); + } + _ => {} + } } /// Draw a named [`Icon`] into `rect`. pub fn paint_icon_in(ui: &Ui, rect: Rect, icon: Icon, color: Color32) { match icon { - Icon::Plus => paint_icon(ui.painter(), rect, Icon::Plus, color), + Icon::Plus | Icon::Copy => paint_icon(ui.painter(), rect, icon, color), other => paint_emoji_in(ui, rect, other.emoji(), color), } } @@ -422,10 +446,10 @@ fn decode_png_rgba(bytes: &[u8]) -> Option { Some(ColorImage::from_rgba_unmultiplied([w, h], &rgba)) } -fn paint_plus(ui: &mut Ui, color: Color32, size: f32) -> Response { +fn paint_stroke_icon(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); if ui.is_rect_visible(rect) { - paint_icon(ui.painter(), rect, Icon::Plus, color); + paint_icon(ui.painter(), rect, icon, color); } response } diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..aa5ac90 --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,496 @@ +//! Layout composition surface — width-safe, compact-by-default primitives. +//! +//! Apps should prefer these helpers over raw `set_max_width` / `Layout::…` +//! plumbing. Escape hatches stay (egui is still available); this module makes +//! the **good defaults** the short path. +//! +//! # Patterns → primitives +//! +//! | App footgun | Primitive | +//! |-------------|-----------| +//! | Page scrolls off / under edge | [`page_body`] / [`central_page`] | +//! | Card overflows column | [`card`] / [`compact_card`] | +//! | Giant gaps when parent is tall | [`vstack`] (non-justified) | +//! | Fixed tiles stretch across the window | [`pack`] (wrap, hug content) | +//! | Actions clipped off the right | [`lead_trail`] | +//! | Side-by-side vs stack breakpoint | [`two_col`] / [`side_by_side`] | +//! | Rate columns staircase ("waterfall") | [`metric_bps`] / [`metric_rate`] / [`metric_cell`] / [`data_table`] | + +use std::hash::Hash; + +use egui::{ + Align, FontId, Frame, Grid, InnerResponse, Layout, Margin, RichText, ScrollArea, Sense, Stroke, + Ui, Vec2, +}; + +use crate::Theme; + +// ── Pure policy (unit-tested without a window) ────────────────────────────── + +/// Minimum residual width (px) before two equal columns fit with `gap`. +/// +/// `true` means place side-by-side; `false` means stack vertically. +pub fn side_by_side(avail: f32, min_col: f32, gap: f32) -> bool { + avail >= min_col * 2.0 + gap && min_col > 0.0 && avail > 0.0 +} + +/// Default min column width for [`two_col`] when apps pass theme spacing. +pub fn default_min_col(theme: &Theme) -> f32 { + // Roughly one compact card: control + padding. + theme.spacing.control_height * 6.0 + theme.spacing.page +} + +/// Character width of a fixed monospace throughput cell ([`metric_bps`]). +pub const METRIC_BPS_CHARS: usize = 14; + +/// Character width of a fixed monospace event-rate cell ([`metric_rate`]). +pub const METRIC_RATE_CHARS: usize = 10; + +/// Pixel width for a metric cell painted with caption monospace + padding. +pub fn metric_cell_px(theme: &Theme, chars: usize) -> f32 { + // Approximate monospace advance ≈ 0.6 × font size; pad for cell breathing room. + let advance = theme.type_scale.caption * 0.62; + advance * chars as f32 + theme.spacing.sm * 2.0 +} + +/// Left-pad `s` to exactly `width` characters (Unicode scalar count). +/// +/// Longer strings are returned unchanged (width is a minimum for alignment). +pub fn pad_metric(s: &str, width: usize) -> String { + let n = s.chars().count(); + if n >= width { + s.to_string() + } else { + format!("{:>width$}", s, width = width) + } +} + +/// Human-readable throughput (B/s → KiB/s, …) without padding. +pub fn format_bps(bps: f64) -> String { + const UNITS: [&str; 5] = ["B/s", "KiB/s", "MiB/s", "GiB/s", "TiB/s"]; + let mut v = bps.max(0.0); + let mut i = 0; + while v >= 1024.0 && i < UNITS.len() - 1 { + v /= 1024.0; + i += 1; + } + if i == 0 { + format!("{v:.0} {}", UNITS[i]) + } else if v >= 100.0 { + format!("{v:.0} {}", UNITS[i]) + } else if v >= 10.0 { + format!("{v:.1} {}", UNITS[i]) + } else { + format!("{v:.2} {}", UNITS[i]) + } +} + +/// Fixed-width throughput for tables (monospace-safe; both edges flush). +pub fn metric_bps(bps: f64) -> String { + pad_metric(&format_bps(bps), METRIC_BPS_CHARS) +} + +/// Human-readable event rate (e.g. write syscalls/s) without padding. +pub fn format_rate(rate: f64) -> String { + if rate < 0.05 { + "0/s".into() + } else if rate < 10.0 { + format!("{rate:.1}/s") + } else if rate < 1000.0 { + format!("{rate:.0}/s") + } else if rate < 1_000_000.0 { + format!("{:.1}k/s", rate / 1000.0) + } else { + format!("{:.1}M/s", rate / 1_000_000.0) + } +} + +/// Fixed-width event rate for tables. +pub fn metric_rate(rate: f64) -> String { + pad_metric(&format_rate(rate), METRIC_RATE_CHARS) +} + +// ── Width-safe scopes ─────────────────────────────────────────────────────── + +/// Pin this scope so children cannot expand past the current available width. +pub fn fit_width(ui: &mut Ui, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { + let w = ui.available_width().max(1.0); + ui.scope(|ui| { + ui.set_max_width(w); + add(ui); + }) +} + +/// Same as [`fit_width`], but also set `min_width` so framed children fill the residual. +pub fn fill_width(ui: &mut Ui, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { + let w = ui.available_width().max(1.0); + ui.scope(|ui| { + ui.set_min_width(w); + ui.set_max_width(w); + add(ui); + }) +} + +/// Non-justified vertical stack — **no giant gaps** when the parent is taller +/// than the content (the usual egui “waterfall” inside stretched cards). +pub fn vstack(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { + let gap = theme.spacing.sm; + ui.with_layout(Layout::top_down(Align::Min), |ui| { + ui.spacing_mut().item_spacing = Vec2::new(ui.spacing().item_spacing.x, gap); + add(ui); + }) +} + +// ── Surfaces ──────────────────────────────────────────────────────────────── + +/// Themed card that **fills** the parent column without overflowing past it. +/// +/// Content is stacked with [`vstack`] so tall parents do not justify gaps. +pub fn card(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { + let outer = ui.available_width().max(1.0); + ui.set_max_width(outer); + theme.card_frame().show(ui, |ui| { + let inner = ui.available_width().max(1.0); + ui.set_min_width(inner); + ui.set_max_width(inner); + vstack(ui, theme, add); + }) +} + +/// Compact card with a **fixed outer width** that hugs content height. +/// +/// Use for gauge tiles and anomaly panels that must not stretch across the +/// window or absorb leftover horizontal space between siblings. +pub fn compact_card( + ui: &mut Ui, + theme: &Theme, + width: f32, + add: impl FnOnce(&mut Ui), +) -> InnerResponse<()> { + let w = width.max(1.0); + // Reserve a tight horizontal slot so neighbors cannot paint over us. + ui.allocate_ui_with_layout( + Vec2::new(w, 0.0), + Layout::top_down(Align::Min), + |ui| { + ui.set_width(w); + ui.set_max_width(w); + theme.card_frame().show(ui, |ui| { + ui.set_width(w - 2.0); + ui.set_max_width(w - 2.0); + vstack(ui, theme, add); + }); + }, + ) +} + +/// Soft-bordered inset row (popover surface) capped to parent width. +pub fn inset_row(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { + let outer = ui.available_width().max(1.0); + ui.set_max_width(outer); + Frame::new() + .fill(theme.palette.popover_bg) + .stroke(Stroke::new(1.0, theme.palette.border_soft)) + .corner_radius(theme.spacing.radius_sm) + .inner_margin(Margin::symmetric( + theme.spacing.md as i8, + theme.spacing.sm as i8, + )) + .show(ui, |ui| { + let w = ui.available_width().max(1.0); + ui.set_min_width(w); + ui.set_max_width(w); + vstack(ui, theme, add); + }) +} + +// ── Horizontal composition ────────────────────────────────────────────────── + +/// Horizontal flow that **wraps** before clipping the edge (toolbars / chips). +pub fn hflow(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { + let w = ui.available_width().max(1.0); + let gap = theme.spacing.sm; + ui.scope(|ui| { + ui.set_max_width(w); + ui.spacing_mut().item_spacing = Vec2::new(gap, gap); + ui.horizontal_wrapped(add); + }) +} + +/// Pack of **compact** children (fixed-size cards/tiles) that wrap without +/// stretching leftover horizontal space into empty gaps between items. +/// +/// Same wrapping as [`hflow`], but spacing defaults to `md` so packs match +/// gauge / anomaly card groups. +pub fn pack(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { + let w = ui.available_width().max(1.0); + let gap = theme.spacing.md; + ui.scope(|ui| { + ui.set_max_width(w); + ui.spacing_mut().item_spacing = Vec2::new(gap, gap); + ui.horizontal_wrapped(add); + }) +} + +/// Leading content grows into remaining width; trailing actions stay visible. +pub fn lead_trail( + ui: &mut Ui, + leading: impl FnOnce(&mut Ui), + trailing: impl FnOnce(&mut Ui), +) -> InnerResponse<()> { + let w = ui.available_width().max(1.0); + ui.scope(|ui| { + ui.set_max_width(w); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + trailing(ui); + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let rest = ui.available_width().max(1.0); + ui.set_max_width(rest); + ui.set_min_width(rest.min(ui.available_width())); + leading(ui); + }); + }); + }) +} + +/// Two columns when [`side_by_side`] says so; otherwise stack. +pub fn two_col( + ui: &mut Ui, + theme: &Theme, + min_col: f32, + left: impl FnOnce(&mut Ui), + right: impl FnOnce(&mut Ui), +) { + let gap = theme.spacing.md; + let avail = ui.available_width(); + if side_by_side(avail, min_col, gap) { + ui.columns(2, |cols| { + cols[0].set_width(cols[0].available_width()); + left(&mut cols[0]); + cols[1].set_width(cols[1].available_width()); + right(&mut cols[1]); + }); + } else { + left(ui); + ui.add_space(gap); + right(ui); + } +} + +// ── Page shell ────────────────────────────────────────────────────────────── + +/// Scrollable page body: pin width to the panel residual, then scroll vertically. +pub fn page_body(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + let w = ui.available_width().max(1.0); + ui.set_max_width(w); + ScrollArea::vertical() + .auto_shrink([false, false]) + .id_salt("vidya_page_body") + .show(ui, |ui| { + let inner = ui.available_width().max(1.0); + ui.set_max_width(inner); + add(ui); + }); +} + +/// Full central page: themed page frame + [`page_body`]. +pub fn central_page( + ctx: &egui::Context, + theme: &Theme, + add: impl FnOnce(&mut Ui), +) -> egui::InnerResponse<()> { + egui::CentralPanel::default() + .frame(theme.page_frame()) + .show(ctx, |ui| { + page_body(ui, add); + }) +} + +// ── Metrics / tables ──────────────────────────────────────────────────────── + +/// Paint a fixed-width monospace metric string, right-edge aligned in `width` px. +/// +/// Pass values from [`metric_bps`] / [`metric_rate`] so every row shares glyph +/// width and the column edges stay vertical (no rate “waterfall”). +pub fn metric_cell(ui: &mut Ui, theme: &Theme, width: f32, text: &str, secondary: bool) { + let h = theme.type_scale.caption + 8.0; + let (rect, _) = ui.allocate_exact_size(Vec2::new(width.max(1.0), h), Sense::hover()); + if !ui.is_rect_visible(rect) { + return; + } + let color = if secondary { + theme.palette.text_secondary + } else { + theme.palette.text + }; + let font = FontId::monospace(theme.type_scale.caption); + let pos = egui::pos2(rect.right() - theme.spacing.xs.max(2.0), rect.center().y); + ui.painter() + .text(pos, egui::Align2::RIGHT_CENTER, text, font, color); +} + +/// Column kind for [`data_table`]. +#[derive(Debug, Clone, Copy)] +pub enum ColKind { + /// Flexible text (name / path). + Flex, + /// Fixed pixel width, right-aligned monospace (pre-formatted metric string). + Metric { width: f32 }, +} + +/// One column header + kind. +#[derive(Debug, Clone, Copy)] +pub struct Col { + pub header: &'static str, + pub kind: ColKind, +} + +/// Striped data table with mixed flex text + fixed metric columns. +/// +/// `row` is called once per data row; it must push exactly `columns.len()` +/// cells using [`table_text`] / [`table_metric`] (or equivalent). +pub fn data_table( + ui: &mut Ui, + theme: &Theme, + id: impl Hash, + columns: &[Col], + mut row: impl FnMut(&mut Ui, usize), + row_count: usize, +) { + Grid::new(id) + .num_columns(columns.len()) + .spacing([theme.spacing.md, 2.0]) + .min_col_width(40.0) + .striped(true) + .show(ui, |ui| { + // Header + for col in columns { + match col.kind { + ColKind::Flex => { + ui.label( + RichText::new(col.header) + .size(theme.type_scale.caption) + .strong() + .color(theme.palette.text_secondary), + ); + } + ColKind::Metric { width } => { + ui.allocate_ui_with_layout( + Vec2::new(width, theme.type_scale.caption + 6.0), + Layout::right_to_left(Align::Center), + |ui| { + ui.set_min_width(width); + ui.label( + RichText::new(col.header) + .size(theme.type_scale.caption) + .strong() + .color(theme.palette.text_secondary), + ); + }, + ); + } + } + } + ui.end_row(); + + for i in 0..row_count { + row(ui, i); + ui.end_row(); + } + }); +} + +/// Flex text cell for [`data_table`] rows. +pub fn table_text(ui: &mut Ui, theme: &Theme, text: &str, primary: bool) { + let color = if primary { + theme.palette.text + } else { + theme.palette.text_secondary + }; + ui.add( + egui::Label::new( + RichText::new(text) + .size(if primary { + theme.type_scale.body + } else { + theme.type_scale.caption + }) + .color(color), + ) + .truncate(), + ); +} + +/// Metric cell for [`data_table`] rows (`text` should be [`metric_bps`] / [`metric_rate`]). +pub fn table_metric(ui: &mut Ui, theme: &Theme, width: f32, text: &str, secondary: bool) { + metric_cell(ui, theme, width, text, secondary); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::Theme; + + #[test] + fn side_by_side_policy_matches_two_col_breakpoint() { + let gap = 12.0; + let min = 160.0; + assert!(!side_by_side(100.0, min, gap)); + assert!(!side_by_side(min * 2.0 + gap - 1.0, min, gap)); + assert!(side_by_side(min * 2.0 + gap, min, gap)); + assert!(side_by_side(800.0, min, gap)); + assert!(!side_by_side(800.0, 0.0, gap)); + } + + #[test] + fn metric_bps_fixed_char_width() { + for bps in [0.0, 100.0, 44.8 * 1024.0, 2.0 * 1024.0 * 1024.0, 999.0] { + let s = metric_bps(bps); + assert_eq!( + s.chars().count(), + METRIC_BPS_CHARS, + "metric_bps({bps}) = {s:?}" + ); + assert!( + s.ends_with(format_bps(bps).as_str()), + "padding must preserve value" + ); + } + } + + #[test] + fn metric_rate_fixed_char_width() { + for rate in [0.0, 1.5, 134.0, 2600.0, 1_500_000.0] { + let s = metric_rate(rate); + assert_eq!( + s.chars().count(), + METRIC_RATE_CHARS, + "metric_rate({rate}) = {s:?}" + ); + assert!(s.contains(format_rate(rate).as_str()) || s.ends_with(format_rate(rate).as_str())); + } + } + + #[test] + fn pad_metric_is_identity_when_already_wide() { + let long = "123456789012345"; // 15 > 14 + assert_eq!(pad_metric(long, METRIC_BPS_CHARS), long); + } + + #[test] + fn metric_cell_px_scales_with_theme_caption() { + let th = Theme::dark(); + let a = metric_cell_px(&th, METRIC_BPS_CHARS); + let b = metric_cell_px(&th, METRIC_RATE_CHARS); + assert!(a > b); + assert!(a > 40.0); + } + + #[test] + fn default_min_col_positive() { + let th = Theme::dark(); + assert!(default_min_col(&th) > 100.0); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3e0ca13..b2dd9bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,11 @@ //! **before** other panels, or use [`top_header`] so content cannot sit under //! the system status / navigation bars. //! +//! Layout composition: prefer [`page_body`] / [`card`] / [`compact_card`] / +//! [`pack`] / [`vstack`] / [`lead_trail`] / [`two_col`] / [`data_table`] / +//! [`metric_bps`] so apps avoid width overflow, justified-gap stretch, and +//! metric “waterfall” columns. +//! //! ```ignore //! use vidya::{apply_dark, Theme}; //! @@ -17,17 +22,26 @@ mod chrome; mod fonts; mod icons; +mod layout; mod theme; -pub use chrome::{reserve_system_chrome, system_chrome, top_header, SystemChrome}; +pub use chrome::{ + reserve_system_chrome, set_system_chrome, system_chrome, top_header, SystemChrome, +}; pub use fonts::install_symbol_font; pub use icons::{ emoji_icon, emoji_icon_colored, emoji_pack_len, has_emoji_icon, icon, icon_colored, icon_for_emoji, normalize_emoji, paint_emoji_in, paint_icon, paint_icon_in, reaction_chip, twemoji_key, Icon, }; +pub use layout::{ + card, central_page, compact_card, data_table, default_min_col, fill_width, fit_width, + format_bps, format_rate, hflow, inset_row, lead_trail, metric_bps, metric_cell, metric_cell_px, + metric_rate, pack, pad_metric, page_body, side_by_side, table_metric, table_text, two_col, + vstack, Col, ColKind, METRIC_BPS_CHARS, METRIC_RATE_CHARS, +}; pub use theme::{ - body, button, checkbox, destructive_button, dim_label, primary_button, status_dot, + body, button, checkbox, destructive_button, dim_label, icon_button, primary_button, status_dot, text_field_multiline, text_field_singleline, title, title_2, Mode, Palette, Spacing, Theme, TypeScale, }; diff --git a/src/theme.rs b/src/theme.rs index cc97614..3787e14 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -4,8 +4,8 @@ use egui::{ pos2, style::{ScrollStyle, WidgetVisuals}, Button, Color32, CornerRadius, CursorIcon, FontId, Frame, Label, Margin, Response, RichText, - Sense, Shadow, Shape, Stroke, StrokeKind, Style, TextBuffer, TextEdit, Ui, Vec2, Visuals, - WidgetInfo, WidgetType, + Sense, Shadow, Shape, Stroke, StrokeKind, Style, TextBuffer, TextEdit, TextWrapMode, Ui, Vec2, + Visuals, WidgetInfo, WidgetType, }; use crate::install_text_styles; @@ -269,6 +269,14 @@ impl Theme { // Solid gutters: floating bars sit on top of content (egui default). HIG-ish // scroll areas reserve space so lists/text never sit under the thumb. style.spacing.scroll = ScrollStyle::solid(); + // Prefer wrapping at the parent edge over extending (overflow) or hard + // truncate. Pair with layout helpers (`fit_width`, `card`, `hflow`) so + // rows actually receive a finite residual width. + style.wrap_mode = Some(TextWrapMode::Wrap); + // Default TextEdit desired width: fill parent residual (egui still + // clamps to available_width). Avoid the stock 280px “always this wide” + // intrinsic that pushes horizontal rows past the app view. + style.spacing.text_edit_width = 10_000.0; style.interaction.tooltip_delay = 0.4; style.animation_time = 0.12; style @@ -352,6 +360,50 @@ fn widget_visuals(p: &Palette, sp: &Spacing) -> egui::style::Widgets { } } +/// Square tool button with a stroke/emoji [`crate::Icon`] (e.g. copy, plus). +/// +/// Prefer this over text labels in tight rows — text wraps under +/// [`TextWrapMode::Wrap`] and looks broken at small widths. +pub fn icon_button(ui: &mut Ui, theme: &Theme, icon: crate::Icon, tip: &str) -> Response { + use crate::paint_icon_in; + let p = &theme.palette; + let size = theme.spacing.control_height; + let (rect, mut response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + response = response.on_hover_text(tip); + if let Some(cursor) = ui.visuals().interact_cursor { + response = response.on_hover_cursor(cursor); + } + + if ui.is_rect_visible(rect) { + let hovered = response.hovered() && ui.is_enabled(); + let active = response.is_pointer_button_down_on(); + let fill = if active { + p.button_active + } else if hovered { + p.button_hover + } else { + p.button_bg + }; + let border = if hovered { + Stroke::new(1.0_f32, p.border) + } else { + Stroke::new(1.0_f32, p.border_soft) + }; + ui.painter().rect( + rect, + theme.spacing.radius_md, + fill, + border, + StrokeKind::Inside, + ); + let icon_size = size * 0.48; + let icon_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(icon_size)); + paint_icon_in(ui, icon_rect, icon, p.button_fg); + } + + response +} + /// Primary / suggested action (accent filled). pub fn primary_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { let p = &theme.palette; @@ -552,29 +604,37 @@ pub fn dim_label(ui: &mut Ui, theme: &Theme, text: &str) { } /// Single-line text field with theme field padding. +/// +/// Desired width tracks the parent residual so the field fills its slot +/// instead of requesting a fixed 280px and clipping siblings off-screen. pub fn text_field_singleline( ui: &mut Ui, theme: &Theme, text: &mut dyn TextBuffer, ) -> Response { + let w = ui.available_width().max(1.0); ui.add( TextEdit::singleline(text) .margin(theme.text_edit_margin()) + .desired_width(w) .min_size(Vec2::new(0.0, theme.spacing.control_height)), ) } /// Multi-line text field with theme field padding. +/// +/// Fills parent width (clamped by available space) so it stays inside the view. pub fn text_field_multiline( ui: &mut Ui, theme: &Theme, text: &mut dyn TextBuffer, rows: usize, ) -> Response { + let w = ui.available_width().max(1.0); ui.add( TextEdit::multiline(text) .margin(theme.text_edit_margin()) - .desired_width(f32::INFINITY) + .desired_width(w) .desired_rows(rows), ) }