Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ src/
```

**Components** (`components.rs`): `card`, `new_tile`, `icon_button`,
`text_button`, `link`, `badge`, `menu_button`, `menu_item`, `list_row`,
`text_input`, `search_field`, `secret_input`, `toggle`, `modal`,
`text_button`, `icon_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`
(+ `paint_phosphor_glyph`), `data_table` (+ `SortState`,
`sortable_header`), `toast_overlay` (+ `ToastStack`), `chip`,
`content_card`, `inspector_row`, `list_section_label`, `empty_state`.
`content_card`, `inspector_row`, `list_section_label`, `empty_state`,
`gate_overlay`.

## Rules — keep these true

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ All live in `tokito_ui::components` (aliased `c` above). Each takes
| `new_tile` | `new_tile(ui, t, label, sublabel, size) -> Response` | A dashed "create new …" tile. |
| `icon_button` | `icon_button(ui, t, glyph, side) -> Response` | A square, frameless icon button. |
| `text_button` | `text_button(ui, t, kind, label, height) -> Response` | A text button — `ButtonKind::Primary` / `Secondary`. |
| `icon_text_button` | `icon_text_button(ui, t, kind, icon, label, height) -> Response` | `text_button` with a leading Phosphor glyph. |
| `link` | `link(ui, t, label) -> Response` | An inline accent-coloured text link. |
| `badge` | `badge(ui, t, text) -> Response` | A small bordered count / status pill. |
| `list_row` | `list_row(ui, t, job, selected) -> Response` | A full-width, left-aligned, hover-highlighted row. |
Expand Down Expand Up @@ -133,6 +134,7 @@ All live in `tokito_ui::components` (aliased `c` above). Each takes
| `inspector_row` | `inspector_row(ui, t, label, value)` | Label-on-left, value-on-right key/value row for inspector / property panels. |
| `list_section_label` | `list_section_label(ui, t, label, count)` | "Symbols (24)" style group label for dense side-panel lists. |
| `empty_state` | `empty_state(ui, t, message)` | Centred "nothing here" placeholder card. |
| `gate_overlay` | `gate_overlay(ui, t, badge_icon, heading, body, button_icon, button_label) -> bool` | Full-bleed gated/locked state — heavy scrim, brand tile + corner badge, display heading, subline, primary CTA. Returns `true` when the CTA is clicked. |
| `chat_activity` | `chat_activity(ui, t, id_source, label, detail)` | Assistant loading/activity bubble with subtle dots and status copy for slow background work. |

### Icons
Expand Down
201 changes: 180 additions & 21 deletions src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,26 +158,11 @@ pub enum ButtonKind {
Secondary,
}

/// A text button. `height` fixes the row height; width fits the label.
pub fn text_button(
ui: &mut Ui,
t: &Tokens,
kind: ButtonKind,
label: &str,
height: f32,
) -> Response {
// PLACEHOLDER so the galley has no baked colour — `Painter::galley`'s
// fallback colour then applies, letting us colour by hover state.
let galley = ui.painter().layout_no_wrap(
label.to_owned(),
TextStyle::Button.resolve(ui.style()),
Color32::PLACEHOLDER,
);
let width = galley.size().x + t.space_4 * 2.0;
let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click());
let hv = hover_t(ui, response.id, response.hovered());

let (fill, ink, border) = match kind {
/// Fill / ink / border for a [`ButtonKind`] at hover factor `hv`. Shared by
/// [`text_button`] and [`icon_text_button`] so the two stay visually
/// identical — only the content laid out inside the rect differs.
fn button_visuals(t: &Tokens, kind: ButtonKind, hv: f32) -> (Color32, Color32, Option<Color32>) {
match kind {
ButtonKind::Primary => (
lerp_color(t.accent, lighten(t.accent, 0.12), hv),
t.accent_ink,
Expand All @@ -188,7 +173,13 @@ pub fn text_button(
lerp_color(t.text_2, t.text, hv),
Some(lerp_color(t.border, t.border_strong, hv)),
),
};
}
}

/// Paint a button's rounded fill + optional border. Shared tail of
/// [`text_button`] / [`icon_text_button`] after each lays out its own
/// content (label-only vs. icon-plus-label).
fn paint_button_chrome(ui: &Ui, rect: Rect, t: &Tokens, fill: Color32, border: Option<Color32>) {
ui.painter().rect_filled(rect, t.rounding_sm(), fill);
if let Some(b) = border {
ui.painter().rect_stroke(
Expand All @@ -198,11 +189,78 @@ pub fn text_button(
egui::StrokeKind::Outside,
);
}
}

/// A text button. `height` fixes the row height; width fits the label.
pub fn text_button(
ui: &mut Ui,
t: &Tokens,
kind: ButtonKind,
label: &str,
height: f32,
) -> Response {
// PLACEHOLDER so the galley has no baked colour — `Painter::galley`'s
// fallback colour then applies, letting us colour by hover state.
let galley = ui.painter().layout_no_wrap(
label.to_owned(),
TextStyle::Button.resolve(ui.style()),
Color32::PLACEHOLDER,
);
let width = galley.size().x + t.space_4 * 2.0;
let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click());
let hv = hover_t(ui, response.id, response.hovered());

let (fill, ink, border) = button_visuals(t, kind, hv);
paint_button_chrome(ui, rect, t, fill, border);
ui.painter()
.galley(rect.center() - galley.size() / 2.0, galley, ink);
response
}

/// A [`text_button`] with a leading Phosphor glyph (`icon`, an
/// [`icons::ph`] constant). Same visual weights and hover animation as
/// [`text_button`] — use this when the action reads better with an icon
/// ("Sign in to Tokito Cloud", "Retry"), and plain `text_button` otherwise.
pub fn icon_text_button(
ui: &mut Ui,
t: &Tokens,
kind: ButtonKind,
icon: &str,
label: &str,
height: f32,
) -> Response {
let icon_size = height * 0.46;
let gap = t.space_2;
let galley = ui.painter().layout_no_wrap(
label.to_owned(),
TextStyle::Button.resolve(ui.style()),
Color32::PLACEHOLDER,
);
let width = icon_size + gap + galley.size().x + t.space_4 * 2.0;
let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click());
let hv = hover_t(ui, response.id, response.hovered());

let (fill, ink, border) = button_visuals(t, kind, hv);
paint_button_chrome(ui, rect, t, fill, border);

let content_w = icon_size + gap + galley.size().x;
let start_x = rect.center().x - content_w / 2.0;
let icon_pos = pos2(start_x, rect.center().y);
ui.painter().text(
icon_pos,
egui::Align2::LEFT_CENTER,
icon,
icons::font(icon_size),
ink,
);
let label_pos = pos2(
start_x + icon_size + gap,
rect.center().y - galley.size().y / 2.0,
);
ui.painter().galley(label_pos, galley, ink);
response
}

/// An inline text link in the accent colour.
pub fn link(ui: &mut Ui, t: &Tokens, label: &str) -> Response {
let resp = ui.add(
Expand Down Expand Up @@ -1625,6 +1683,107 @@ pub fn empty_state(ui: &mut Ui, t: &Tokens, message: &str) {
});
}

// ---------------------------------------------------------------------------
// gate_overlay
// ---------------------------------------------------------------------------

/// A full-bleed gated / locked state: a heavy tinted scrim filling `ui`'s
/// current available rect, with a centred vertical stack — a rounded
/// brand-tile visual carrying a small corner badge glyph, a display-weight
/// heading, one muted subline, and a primary CTA with a leading icon.
///
/// egui has no real backdrop blur, so the scrim is approximated with a
/// high-opacity base fill plus a soft two-layer accent glow behind the
/// card for depth, rather than a translucent wash over live content —
/// give this its own `Ui` scoped to exactly the region that should read as
/// gated (e.g. a `CentralPanel` spanning a message list *and* its
/// composer), not a thin strip over just part of it.
///
/// Generalized on purpose: only the icon, heading, body copy, and button
/// icon/label are baked in here — what the button *does* is entirely the
/// caller's concern. Today's only consumer is the chat panel's Tokito
/// Cloud sign-in gate, but any future gated/empty chrome state (a locked
/// feature, an unconfigured integration, …) can reuse this unchanged.
///
/// Returns `true` on the frame the CTA is clicked.
#[allow(clippy::too_many_arguments)]
pub fn gate_overlay(
ui: &mut Ui,
t: &Tokens,
badge_icon: &str,
heading: &str,
body: &str,
button_icon: &str,
button_label: &str,
) -> bool {
let rect = ui.available_rect_before_wrap();
// Claim the whole area so nothing behind it is clickable while gated.
ui.allocate_rect(rect, Sense::hover());

// Layer 1: a near-opaque base — the closest egui gets to a heavy
// backdrop scrim without real blur support.
let scrim = if t.dark {
t.bg.gamma_multiply(0.97)
} else {
t.bg
};
ui.painter().rect_filled(rect, 0.0, scrim);

// Layer 2: a soft two-ring accent glow centred behind the card, for
// depth — flat fills, not a real blur, but reads as intentional rather
// than a plain empty-state grey box.
let glow_r = (rect.width().min(rect.height()) * 0.42).max(120.0);
ui.painter()
.circle_filled(rect.center(), glow_r, t.accent_soft);
ui.painter()
.circle_filled(rect.center(), glow_r * 0.6, t.accent_2_soft);

let mut clicked = false;
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
ui.allocate_ui_with_layout(rect.size(), Layout::top_down(Align::Center), |ui| {
ui.add_space((rect.height() * 0.5 - 150.0).max(rect.height() * 0.12));

// Visual: the brand tile with a small lock badge overlapped at
// its bottom-right corner (falls back to a plain rounded tile
// if the caller ever needs a badge-less variant — not needed
// today, so not parameterised yet).
let tile_side = 72.0;
let tile_resp = crate::brand::brand_tile(ui, tile_side);
let badge_d = 30.0;
let badge_c = tile_resp.rect.right_bottom() - Vec2::splat(badge_d * 0.32);
// Separation ring so the badge reads as sitting *on* the tile
// rather than merging into it.
ui.painter()
.circle_filled(badge_c, badge_d * 0.5 + 3.0, scrim);
ui.painter().circle_filled(badge_c, badge_d * 0.5, t.accent);
ui.painter().text(
badge_c,
egui::Align2::CENTER_CENTER,
badge_icon,
icons::font(badge_d * 0.56),
t.accent_ink,
);

ui.add_space(t.space_5);
ui.label(
RichText::new(heading)
.text_style(TextStyle::Heading)
.strong()
.color(t.text),
);
ui.add_space(t.space_2);
ui.label(RichText::new(body).size(14.0).color(t.text_2));
ui.add_space(t.space_5);
if icon_text_button(ui, t, ButtonKind::Primary, button_icon, button_label, 38.0)
.clicked()
{
clicked = true;
}
});
});
clicked
}

// ---------------------------------------------------------------------------
// app_header
// ---------------------------------------------------------------------------
Expand Down