From 0ae5321bde80569465e3819b892dc50964e46e06 Mon Sep 17 00:00:00 2001 From: Nikhil Ranjan Date: Wed, 20 May 2026 12:33:52 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20shadcn/ui-inspired=20component?= =?UTF-8?q?=20kit=20=E2=80=94=20buttons,=20inputs,=20switches,=20cards,=20?= =?UTF-8?q?badges,=20separators,=20progress,=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a rich set of UI widget primitives to the Oxide host/SDK boundary, modelled after the shadcn/ui design system. Guests can now compose native-looking UIs entirely through the FFI widget layer. Key changes: Host (oxide-browser): - `WidgetVariant` enum (Default, Secondary, Outline, Ghost, Destructive) with colour scheme helpers (variant_colors, variant_hover_bg) - Extended `WidgetCommand` with 8 new widget types: • Textarea — multi-line text input with scroll support • Card — container with title/description, border, rounded corners • Badge — pill-shaped status indicator with variant support • Switch — pill-shaped on/off toggle (bool state in WidgetValue) • Separator — 1px horizontal or vertical divider • Progress — horizontal progress bar (0.0..=1.0) • Label — static text label with muted variant and font size control - Updated Button to accept variant; TextInput now carries placeholder - Full keyboard input handling for text fields (handle_widget_key): • ←/→/↑/↓/Home/End/PgUp/PgDn navigation • Shift-selection (extending from anchor) • Cmd/Ctrl+A/C/X/V (select-all, copy, cut, paste) • Backspace/Delete with selection support • Enter inserts newline in textareas • UTF-8 boundary-aware cursor movement - GPUI render functions for all widget types with proper: • Font shaping via Window::text_system() • Selection highlight painting • Blinking caret • Placeholder text in muted colour • Mouse down/move/up for text selection • Scroll wheel in textareas • Hover and click state for interactive widgets - `widget_bounds` now returns Option — decorative widgets (Badge, Separator, Progress, Label) do not block canvas click-through - New host functions registered: api_ui_textarea, api_ui_switch, api_ui_card, api_ui_badge, api_ui_separator, api_ui_progress, SDK (oxide-sdk): - `UiVariant` enum with `as_u32()` codec - FFI declarations for all new host functions - `ui_button_variant(id, x, y, w, h, label, variant)` — builder with style - `ui_text_input` now takes placeholder; `ui_text_input_with_value` seeds init - `ui_textarea` / `ui_textarea_with_value` — multi-line with 16 KiB buffer - `ui_switch`, `ui_card`, `ui_badge`, `ui_separator`, `ui_separator_vertical`, `ui_progress`, `ui_label`, `ui_label_muted` — complete surface area - All widgets documented with /// doc comments Example (examples/shadcn-demo): - New workspace member showcasing every widget primitive in a single-page tour: buttons (5 variants), badges (5 variants), form with email/password/message fields, checkbox, switch, live preview card, sliders, progress bar, labels, separator Example (examples/hello-oxide): - Text input now defaults to "Type your name…" placeholder Builds: - Cargo.toml / Cargo.lock updated for new workspace member --- Cargo.lock | 7 + Cargo.toml | 1 + examples/hello-oxide/src/lib.rs | 2 +- examples/shadcn-demo/Cargo.toml | 11 + examples/shadcn-demo/src/lib.rs | 156 +++ oxide-browser/src/capabilities.rs | 313 +++++- oxide-browser/src/ui.rs | 1734 +++++++++++++++++++++++++---- oxide-sdk/src/lib.rs | 246 +++- 8 files changed, 2214 insertions(+), 256 deletions(-) create mode 100644 examples/shadcn-demo/Cargo.toml create mode 100644 examples/shadcn-demo/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7ee9a52..d48882f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7228,6 +7228,13 @@ dependencies = [ "digest", ] +[[package]] +name = "shadcn-demo" +version = "0.1.0" +dependencies = [ + "oxide-sdk", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index b00f0ba..3fd31c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,5 +21,6 @@ members = [ "examples/file-picker-demo", "examples/gradient-demo", "examples/typography-demo", + "examples/shadcn-demo", ] resolver = "2" diff --git a/examples/hello-oxide/src/lib.rs b/examples/hello-oxide/src/lib.rs index f6bf20d..b1d308f 100644 --- a/examples/hello-oxide/src/lib.rs +++ b/examples/hello-oxide/src/lib.rs @@ -98,7 +98,7 @@ pub extern "C" fn on_frame(_dt_ms: u32) { // ── Text input demo ───────────────────────────────────────────── canvas_text(20.0, 355.0, 16.0, 180, 140, 255, 255, "Text Input"); - let name = ui_text_input(30, 20.0, 380.0, 300.0, ""); + let name = ui_text_input(30, 20.0, 380.0, 300.0, "Type your name…"); if !name.is_empty() { canvas_text( 20.0, diff --git a/examples/shadcn-demo/Cargo.toml b/examples/shadcn-demo/Cargo.toml new file mode 100644 index 0000000..7f531fe --- /dev/null +++ b/examples/shadcn-demo/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shadcn-demo" +version = "0.1.0" +edition = "2021" +description = "Tour of Oxide's shadcn/ui-inspired widget primitives" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +oxide-sdk = { path = "../../oxide-sdk" } diff --git a/examples/shadcn-demo/src/lib.rs b/examples/shadcn-demo/src/lib.rs new file mode 100644 index 0000000..26d0f8e --- /dev/null +++ b/examples/shadcn-demo/src/lib.rs @@ -0,0 +1,156 @@ +//! Tour of Oxide's shadcn/ui-inspired widget primitives. +//! +//! Each section demonstrates one component family. Run with: +//! +//! ```bash +//! cargo build --target wasm32-unknown-unknown --release -p shadcn-demo +//! ``` +//! +//! Then open the resulting `.wasm` from `target/wasm32-unknown-unknown/release/shadcn_demo.wasm` +//! inside the Oxide browser (File → Open) or pass its path on the command line. + +use oxide_sdk::*; + +#[no_mangle] +pub extern "C" fn start_app() { + log("shadcn-demo loaded"); +} + +#[no_mangle] +pub extern "C" fn on_frame(_dt_ms: u32) { + // Match the host's dark surface so the page feels native. + canvas_clear(0x0a, 0x0a, 0x0b, 0xff); + set_content_size(960, 940); + + // ── Header ────────────────────────────────────────────────────── + ui_label(24.0, 24.0, "Oxide UI Kit", 28.0); + ui_label_muted( + 24.0, + 58.0, + "shadcn/ui-inspired primitives rendered by the host.", + 14.0, + ); + ui_separator(24.0, 92.0, 912.0); + + // ── Buttons row ───────────────────────────────────────────────── + ui_label(24.0, 112.0, "Buttons", 16.0); + let _ = ui_button_variant(100, 24.0, 140.0, 110.0, 36.0, "Default", UiVariant::Default); + let _ = ui_button_variant( + 101, + 144.0, + 140.0, + 110.0, + 36.0, + "Secondary", + UiVariant::Secondary, + ); + let _ = ui_button_variant( + 102, + 264.0, + 140.0, + 110.0, + 36.0, + "Outline", + UiVariant::Outline, + ); + let _ = ui_button_variant(103, 384.0, 140.0, 110.0, 36.0, "Ghost", UiVariant::Ghost); + let _ = ui_button_variant( + 104, + 504.0, + 140.0, + 110.0, + 36.0, + "Destructive", + UiVariant::Destructive, + ); + + // ── Badges ────────────────────────────────────────────────────── + ui_label(24.0, 204.0, "Badges", 16.0); + ui_badge(24.0, 232.0, "Default", UiVariant::Default); + ui_badge(108.0, 232.0, "Secondary", UiVariant::Secondary); + ui_badge(204.0, 232.0, "Outline", UiVariant::Outline); + ui_badge(288.0, 232.0, "Destructive", UiVariant::Destructive); + ui_badge(396.0, 232.0, "v0.7.0", UiVariant::Ghost); + + ui_separator(24.0, 276.0, 912.0); + + // ── Form section: inputs + switches ───────────────────────────── + ui_label(24.0, 296.0, "Form", 16.0); + + ui_label_muted(24.0, 326.0, "Email", 13.0); + let email = ui_text_input(200, 24.0, 348.0, 360.0, "name@example.com"); + + ui_label_muted(24.0, 396.0, "Password", 13.0); + let _password = ui_text_input(201, 24.0, 418.0, 360.0, "Enter a password"); + + ui_label_muted(24.0, 466.0, "Message", 13.0); + let message = ui_textarea( + 202, + 24.0, + 488.0, + 360.0, + 110.0, + "Tell us what's on your mind…", + ); + + let remember = ui_checkbox(210, 24.0, 614.0, "Remember me", true); + let marketing = ui_switch(211, 200.0, 614.0, "Marketing emails", false); + + let _submit = ui_button_variant(220, 24.0, 654.0, 120.0, 36.0, "Sign in", UiVariant::Default); + let _cancel = ui_button_variant(221, 156.0, 654.0, 100.0, 36.0, "Cancel", UiVariant::Ghost); + + // ── Live preview card ─────────────────────────────────────────── + ui_card( + 420.0, + 296.0, + 516.0, + 296.0, + "Live preview", + "Every field updates this card in real time.", + ); + + let mut row_y = 376.0; + let preview = if email.is_empty() { + "(no email yet)".to_string() + } else { + format!("📧 {email}") + }; + ui_label(440.0, row_y, &preview, 14.0); + row_y += 28.0; + + let lines = message.lines().count(); + let chars = message.chars().count(); + ui_label_muted( + 440.0, + row_y, + &format!("Message: {chars} chars · {lines} lines"), + 13.0, + ); + row_y += 28.0; + + let prefs = format!( + "Remember: {} · Marketing: {}", + if remember { "yes" } else { "no" }, + if marketing { "on" } else { "off" }, + ); + ui_label_muted(440.0, row_y, &prefs, 13.0); + + // ── Sliders + progress ───────────────────────────────────────── + ui_label(24.0, 720.0, "Slider & progress", 16.0); + + let volume = ui_slider(300, 24.0, 752.0, 360.0, 0.0, 100.0, 32.0); + ui_label_muted(400.0, 756.0, &format!("Volume {volume:.0}%"), 13.0); + + let goal = ui_slider(301, 24.0, 794.0, 360.0, 0.0, 1.0, 0.6); + ui_label_muted(400.0, 798.0, "Goal progress", 13.0); + + ui_progress(24.0, 832.0, 912.0, goal); + + ui_separator(24.0, 868.0, 912.0); + ui_label_muted( + 24.0, + 884.0, + "Tip: arrow keys, shift-arrows, Cmd/Ctrl+A/C/X/V all work inside text fields.", + 12.0, + ); +} diff --git a/oxide-browser/src/capabilities.rs b/oxide-browser/src/capabilities.rs index 4d6e9c6..c29d8df 100644 --- a/oxide-browser/src/capabilities.rs +++ b/oxide-browser/src/capabilities.rs @@ -526,6 +526,35 @@ pub struct InputState { pub scroll_y: f32, } +/// Visual emphasis for buttons and badges; matches shadcn/ui variants. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WidgetVariant { + /// High-emphasis (light fill on dark theme). + #[default] + Default, + /// Neutral fill on a muted surface. + Secondary, + /// Transparent fill with a visible border. + Outline, + /// Transparent fill, only shows on hover. + Ghost, + /// Red emphasis for destructive actions / errors. + Destructive, +} + +impl WidgetVariant { + /// Decode the integer flag from SDK calls (`0=Default … 4=Destructive`). + pub fn from_u32(v: u32) -> Self { + match v { + 1 => Self::Secondary, + 2 => Self::Outline, + 3 => Self::Ghost, + 4 => Self::Destructive, + _ => Self::Default, + } + } +} + /// UI control the guest requested for the current frame; the host GPUI layer renders these after canvas content. /// /// Commands are queued during `on_frame`; stable `id` values tie widgets to [`WidgetValue`] state and click tracking. @@ -539,6 +568,7 @@ pub enum WidgetCommand { w: f32, h: f32, label: String, + variant: WidgetVariant, }, /// Toggle with label; checked state lives in [`WidgetValue::Bool`] for this `id`. Checkbox { @@ -557,13 +587,68 @@ pub enum WidgetCommand { max: f32, }, /// Single-line text field; current text stored in [`WidgetValue::Text`]. - TextInput { id: u32, x: f32, y: f32, w: f32 }, + TextInput { + id: u32, + x: f32, + y: f32, + w: f32, + placeholder: String, + }, + /// Multi-line text field with vertical scrolling. + Textarea { + id: u32, + x: f32, + y: f32, + w: f32, + h: f32, + placeholder: String, + }, + /// Container with subtle border and rounded corners. + Card { + x: f32, + y: f32, + w: f32, + h: f32, + title: String, + description: String, + }, + /// Small status pill. + Badge { + x: f32, + y: f32, + label: String, + variant: WidgetVariant, + }, + /// Pill-shaped on/off toggle; bool state in [`WidgetValue::Bool`]. + Switch { + id: u32, + x: f32, + y: f32, + label: String, + }, + /// 1px divider (horizontal or vertical). + Separator { + x: f32, + y: f32, + length: f32, + vertical: bool, + }, + /// Horizontal progress bar; value 0.0..=1.0. + Progress { x: f32, y: f32, w: f32, value: f32 }, + /// Static text label rendered with proper font shaping. + Label { + x: f32, + y: f32, + text: String, + muted: bool, + size: f32, + }, } /// Persistent control state for interactive widgets, keyed by widget `id` across frames. #[derive(Clone, Debug)] pub enum WidgetValue { - /// Checkbox on/off. + /// Checkbox / switch on/off. Bool(bool), /// Slider current value. Float(f32), @@ -3775,7 +3860,8 @@ pub fn register_host_functions(linker: &mut Linker) -> Result<()> { w: f32, h: f32, label_ptr: u32, - label_len: u32| + label_len: u32, + variant: u32| -> u32 { let mem = caller.data().memory.expect("memory not set"); let label = read_guest_string(&mem, &caller, label_ptr, label_len).unwrap_or_default(); @@ -3791,6 +3877,7 @@ pub fn register_host_functions(linker: &mut Linker) -> Result<()> { w, h, label, + variant: crate::capabilities::WidgetVariant::from_u32(variant), }); if caller.data().widget_clicked.lock().unwrap().contains(&id) { 1 @@ -3884,10 +3971,64 @@ pub fn register_host_functions(linker: &mut Linker) -> Result<()> { w: f32, init_ptr: u32, init_len: u32, + placeholder_ptr: u32, + placeholder_len: u32, + out_ptr: u32, + out_cap: u32| + -> u32 { + let mem = caller.data().memory.expect("memory not set"); + let placeholder = read_guest_string(&mem, &caller, placeholder_ptr, placeholder_len) + .unwrap_or_default(); + let text = { + let mut states = caller.data().widget_states.lock().unwrap(); + let entry = states.entry(id).or_insert_with(|| { + let init = + read_guest_string(&mem, &caller, init_ptr, init_len).unwrap_or_default(); + WidgetValue::Text(init) + }); + match entry { + WidgetValue::Text(t) => t.clone(), + _ => String::new(), + } + }; + caller + .data() + .widget_commands + .lock() + .unwrap() + .push(WidgetCommand::TextInput { + id, + x, + y, + w, + placeholder, + }); + let bytes = text.as_bytes(); + let write_len = bytes.len().min(out_cap as usize); + write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok(); + write_len as u32 + }, + )?; + + linker.func_wrap( + "oxide", + "api_ui_textarea", + |mut caller: Caller<'_, HostState>, + id: u32, + x: f32, + y: f32, + w: f32, + h: f32, + init_ptr: u32, + init_len: u32, + placeholder_ptr: u32, + placeholder_len: u32, out_ptr: u32, out_cap: u32| -> u32 { let mem = caller.data().memory.expect("memory not set"); + let placeholder = read_guest_string(&mem, &caller, placeholder_ptr, placeholder_len) + .unwrap_or_default(); let text = { let mut states = caller.data().widget_states.lock().unwrap(); let entry = states.entry(id).or_insert_with(|| { @@ -3905,7 +4046,14 @@ pub fn register_host_functions(linker: &mut Linker) -> Result<()> { .widget_commands .lock() .unwrap() - .push(WidgetCommand::TextInput { id, x, y, w }); + .push(WidgetCommand::Textarea { + id, + x, + y, + w, + h, + placeholder, + }); let bytes = text.as_bytes(); let write_len = bytes.len().min(out_cap as usize); write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok(); @@ -3913,6 +4061,163 @@ pub fn register_host_functions(linker: &mut Linker) -> Result<()> { }, )?; + linker.func_wrap( + "oxide", + "api_ui_switch", + |caller: Caller<'_, HostState>, + id: u32, + x: f32, + y: f32, + label_ptr: u32, + label_len: u32, + initial: u32| + -> u32 { + let mem = caller.data().memory.expect("memory not set"); + let label = read_guest_string(&mem, &caller, label_ptr, label_len).unwrap_or_default(); + let mut states = caller.data().widget_states.lock().unwrap(); + let entry = states + .entry(id) + .or_insert_with(|| WidgetValue::Bool(initial != 0)); + let checked = match entry { + WidgetValue::Bool(b) => *b, + _ => initial != 0, + }; + drop(states); + caller + .data() + .widget_commands + .lock() + .unwrap() + .push(WidgetCommand::Switch { id, x, y, label }); + if checked { + 1 + } else { + 0 + } + }, + )?; + + linker.func_wrap( + "oxide", + "api_ui_card", + |caller: Caller<'_, HostState>, + x: f32, + y: f32, + w: f32, + h: f32, + title_ptr: u32, + title_len: u32, + desc_ptr: u32, + desc_len: u32| { + let mem = caller.data().memory.expect("memory not set"); + let title = read_guest_string(&mem, &caller, title_ptr, title_len).unwrap_or_default(); + let description = + read_guest_string(&mem, &caller, desc_ptr, desc_len).unwrap_or_default(); + caller + .data() + .widget_commands + .lock() + .unwrap() + .push(WidgetCommand::Card { + x, + y, + w, + h, + title, + description, + }); + }, + )?; + + linker.func_wrap( + "oxide", + "api_ui_badge", + |caller: Caller<'_, HostState>, + x: f32, + y: f32, + label_ptr: u32, + label_len: u32, + variant: u32| { + let mem = caller.data().memory.expect("memory not set"); + let label = read_guest_string(&mem, &caller, label_ptr, label_len).unwrap_or_default(); + caller + .data() + .widget_commands + .lock() + .unwrap() + .push(WidgetCommand::Badge { + x, + y, + label, + variant: crate::capabilities::WidgetVariant::from_u32(variant), + }); + }, + )?; + + linker.func_wrap( + "oxide", + "api_ui_separator", + |caller: Caller<'_, HostState>, x: f32, y: f32, length: f32, vertical: u32| { + caller + .data() + .widget_commands + .lock() + .unwrap() + .push(WidgetCommand::Separator { + x, + y, + length, + vertical: vertical != 0, + }); + }, + )?; + + linker.func_wrap( + "oxide", + "api_ui_progress", + |caller: Caller<'_, HostState>, x: f32, y: f32, w: f32, value: f32| { + caller + .data() + .widget_commands + .lock() + .unwrap() + .push(WidgetCommand::Progress { + x, + y, + w, + value: value.clamp(0.0, 1.0), + }); + }, + )?; + + linker.func_wrap( + "oxide", + "api_ui_label", + |caller: Caller<'_, HostState>, + x: f32, + y: f32, + text_ptr: u32, + text_len: u32, + muted: u32, + size: f32| { + let mem = caller.data().memory.expect("memory not set"); + let text = read_guest_string(&mem, &caller, text_ptr, text_len).unwrap_or_default(); + let size = if size <= 0.0 { 14.0 } else { size }; + caller + .data() + .widget_commands + .lock() + .unwrap() + .push(WidgetCommand::Label { + x, + y, + text, + muted: muted != 0, + size, + }); + }, + )?; + crate::media_capture::register_media_capture_functions(linker)?; // ── GPU / WebGPU-style API ─────────────────────────────────────── diff --git a/oxide-browser/src/ui.rs b/oxide-browser/src/ui.rs index dcddf5f..0d03c70 100644 --- a/oxide-browser/src/ui.rs +++ b/oxide-browser/src/ui.rs @@ -30,8 +30,38 @@ use smallvec::smallvec; use crate::bookmarks::BookmarkStore; use crate::capabilities::{ - ConsoleLevel, DrawCommand, GradientStop, HostState, WidgetCommand, WidgetValue, + ConsoleLevel, DrawCommand, GradientStop, HostState, WidgetCommand, WidgetValue, WidgetVariant, }; + +/// shadcn-inspired neutral dark palette used across the desktop shell and guest widgets. +/// +/// Mirrors the default zinc-based dark theme: subtle borders, low-contrast surfaces, +/// high-contrast foreground text. Kept as raw RGB so the values match GPUI helpers exactly. +#[allow(dead_code)] +mod theme { + use gpui::Rgba; + + pub const BG: u32 = 0x0a0a0b; // background — page chrome + pub const SURFACE: u32 = 0x18181b; // raised panel + pub const SURFACE_HOVER: u32 = 0x27272a; // hover state on surfaces + pub const MUTED: u32 = 0x27272a; // muted control fill (input, switch off) + pub const BORDER: u32 = 0x27272a; // 1px outlines + pub const BORDER_STRONG: u32 = 0x3f3f46; // emphasised outlines + pub const RING: u32 = 0xd4d4d8; // focus ring + pub const PRIMARY: u32 = 0xfafafa; // primary fill (button default) + pub const PRIMARY_FG: u32 = 0x18181b; // text on primary fill + pub const FG: u32 = 0xfafafa; // body text + pub const FG_MUTED: u32 = 0xa1a1aa; // secondary text + pub const FG_DIM: u32 = 0x71717a; // disabled / placeholder text + pub const ACCENT: u32 = 0x60a5fa; // info / link + pub const DESTRUCTIVE: u32 = 0x7f1d1d; // destructive button + pub const SUCCESS: u32 = 0x16a34a; // success badge + + /// Translucent selection highlight (matches a thin ring on dark backgrounds). + pub fn selection() -> Rgba { + gpui::rgba(0x60a5fa55) + } +} use crate::download::{format_bytes, DownloadManager, DownloadState}; use crate::engine::ModuleLoader; use crate::forge::{ @@ -109,6 +139,34 @@ fn format_friendly_timestamp(timestamp_ms: u64) -> String { } } +/// Caret + selection + scroll offsets for an editable widget, keyed off the widget id. +/// +/// Persisted on [`TabState`] across frames so guests don't have to thread cursor state +/// through their own UI loop — the host owns the editing model. +#[derive(Clone, Debug, Default)] +struct WidgetEditState { + /// Caret byte offset within the widget text. + cursor: usize, + /// Selection anchor; equal to `cursor` when there is no selection. + sel_start: usize, + /// True while the user is drag-selecting with the mouse. + selecting: bool, + /// Vertical scroll offset for the textarea, in pixels. + scroll_y: f32, +} + +impl WidgetEditState { + fn move_to(&mut self, offset: usize, max: usize) { + let o = offset.min(max); + self.cursor = o; + self.sel_start = o; + } + + fn select_to(&mut self, offset: usize, max: usize) { + self.cursor = offset.min(max); + } +} + struct TabState { id: u64, url_input: String, @@ -129,6 +187,10 @@ struct TabState { keys_held: HashSet, /// Guest `TextInput` widget id with keyboard focus, if any. text_input_focus: Option, + /// Cursor/selection state per editable widget id (text input + textarea). + widget_edits: HashMap, + /// Bounds of editable widget text areas in window coords, for mouse hit-testing. + widget_bounds_cache: HashMap>>>, /// Cursor byte offset in `url_input`. url_cursor: usize, /// Selection anchor byte offset; when != `url_cursor`, the range between them is selected. @@ -186,6 +248,8 @@ impl TabState { last_frame: Instant::now(), keys_held: HashSet::new(), text_input_focus: None, + widget_edits: HashMap::new(), + widget_bounds_cache: HashMap::new(), url_cursor: 12, url_sel_start: 12, url_selecting: false, @@ -1554,7 +1618,7 @@ impl Render for OxideBrowserView { .size_full() .flex() .flex_col() - .bg(gpui::rgb(0x1a1a20)) + .bg(gpui::rgb(theme::BG)) .on_key_down(cx.listener( |this: &mut OxideBrowserView, event: &KeyDownEvent, window, cx| { { @@ -1709,48 +1773,7 @@ impl Render for OxideBrowserView { return; } if let Some(id) = this.tabs[this.active_tab].text_input_focus { - let mut states = this.tabs[this.active_tab] - .host_state - .widget_states - .lock() - .unwrap(); - let mut text = match states.get(&id) { - Some(WidgetValue::Text(t)) => t.clone(), - _ => String::new(), - }; - if event.keystroke.modifiers.secondary() { - match event.keystroke.key.as_str() { - "c" => { - if let Ok(mut cb) = arboard::Clipboard::new() { - let _ = cb.set_text(&text); - } - } - "v" => { - if let Ok(mut cb) = arboard::Clipboard::new() { - if let Ok(pasted) = cb.get_text() { - text.push_str(&pasted); - states.insert(id, WidgetValue::Text(text)); - } - } - } - "x" => { - if let Ok(mut cb) = arboard::Clipboard::new() { - let _ = cb.set_text(&text); - } - states.insert(id, WidgetValue::Text(String::new())); - } - "a" => {} - _ => {} - } - cx.notify(); - return; - } - if event.keystroke.key == "backspace" { - text.pop(); - } else if let Some(s) = text_insert_from_keystroke(&event.keystroke) { - text.push_str(&s); - } - states.insert(id, WidgetValue::Text(text)); + handle_widget_key(this, id, event); cx.notify(); return; } @@ -1945,14 +1968,14 @@ impl Render for OxideBrowserView { .flex_row() .items_center() .h(px(32.0)) - .px_2() + .px_3() .rounded_md() - .bg(gpui::rgb(0x121218)) + .bg(gpui::rgb(theme::SURFACE)) .border_1() .border_color(if url_focused { - gpui::rgb(0x6a6aff) + gpui::rgb(theme::RING) } else { - gpui::rgb(0x121218) + gpui::rgb(theme::BORDER) }) .track_focus(&self.url_focus) .overflow_hidden() @@ -2199,7 +2222,7 @@ impl Render for OxideBrowserView { ); window.paint_quad(gpui::fill( sel_bounds, - rgba8(0x44, 0x66, 0xcc, 0x70), + theme::selection(), )); } @@ -3961,6 +3984,22 @@ impl Render for OxideBrowserView { .lock() .unwrap() .clone(); + let widget_edits_snapshot = self.tabs[active].widget_edits.clone(); + + // Ensure each editable widget has a stable bounds cache so mouse hit-tests + // can locate the text canvas after layout. + for cmd in &widget_commands { + match cmd { + WidgetCommand::TextInput { id, .. } | WidgetCommand::Textarea { id, .. } => { + self.tabs[active] + .widget_bounds_cache + .entry(*id) + .or_insert_with(|| Arc::new(Mutex::new(Bounds::default()))); + } + _ => {} + } + } + let widget_bounds_cache_snapshot = self.tabs[active].widget_bounds_cache.clone(); let canvas_with_widgets = widget_commands @@ -3973,33 +4012,8 @@ impl Render for OxideBrowserView { w, h, label, - } => el.child( - div() - .id(("oxide_btn", id as usize)) - .absolute() - .left(px(x)) - .top(px(y)) - .w(px(w)) - .h(px(h)) - .flex() - .items_center() - .justify_center() - .rounded_md() - .bg(gpui::rgb(0x3a3a48)) - .cursor_pointer() - .text_sm() - .text_color(gpui::rgb(0xe8e8f0)) - .child(label) - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - this.tabs[this.active_tab] - .host_state - .widget_clicked - .lock() - .unwrap() - .insert(id); - cx.notify(); - })), - ), + variant, + } => el.child(render_button(cx, id, x, y, w, h, label, variant)), WidgetCommand::Checkbox { id, x, y, label } => { let checked = widget_states_snapshot .get(&id) @@ -4008,48 +4022,17 @@ impl Render for OxideBrowserView { _ => None, }) .unwrap_or(false); - el.child( - div() - .id(("oxide_cb", id as usize)) - .absolute() - .left(px(x)) - .top(px(y)) - .w(px(220.0)) - .h(px(30.0)) - .flex() - .flex_row() - .items_center() - .gap_2() - .cursor_pointer() - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - let mut states = this.tabs[this.active_tab] - .host_state - .widget_states - .lock() - .unwrap(); - let cur = states - .get(&id) - .and_then(|v| match v { - WidgetValue::Bool(b) => Some(*b), - _ => None, - }) - .unwrap_or(false); - states.insert(id, WidgetValue::Bool(!cur)); - cx.notify(); - })) - .child( - div() - .text_sm() - .text_color(gpui::rgb(0xa0a0aa)) - .child(if checked { "☑" } else { "☐" }), - ) - .child( - div() - .text_sm() - .text_color(gpui::rgb(0xd0d0dc)) - .child(label), - ), - ) + el.child(render_checkbox(cx, id, x, y, &label, checked)) + } + WidgetCommand::Switch { id, x, y, label } => { + let checked = widget_states_snapshot + .get(&id) + .and_then(|v| match v { + WidgetValue::Bool(b) => Some(*b), + _ => None, + }) + .unwrap_or(false); + el.child(render_switch(cx, id, x, y, &label, checked)) } WidgetCommand::Slider { id, @@ -4066,75 +4049,15 @@ impl Render for OxideBrowserView { _ => None, }) .unwrap_or(min); - let frac = if max > min { - ((cur - min) / (max - min)).clamp(0.0, 1.0) - } else { - 0.0 - }; - let handle_size: f32 = 14.0; - let handle_left = - (frac * w - handle_size / 2.0).clamp(0.0, w - handle_size); - el.child( - div() - .id(("oxide_sl", id as usize)) - .absolute() - .left(px(x)) - .top(px(y)) - .w(px(w)) - .h(px(28.0)) - .flex() - .items_center() - .justify_end() - .pr_2() - .rounded_md() - .bg(gpui::rgb(0x2a2a32)) - .on_click(cx.listener( - move |this, event: &ClickEvent, _, cx| { - if let Some(pos) = event.mouse_position() { - let tab = &mut this.tabs[this.active_tab]; - let (ox, _) = - *tab.host_state.canvas_offset.lock().unwrap(); - let lx = f32::from(pos.x) - ox; - let frac = ((lx - x) / w).clamp(0.0, 1.0); - let v = min + frac * (max - min); - tab.host_state - .widget_states - .lock() - .unwrap() - .insert(id, WidgetValue::Float(v)); - cx.notify(); - } - }, - )) - .child( - div() - .absolute() - .left(px(0.0)) - .top(px(12.0)) - .w(px(frac * w)) - .h(px(4.0)) - .rounded_sm() - .bg(gpui::rgb(0x5a5a8a)), - ) - .child( - div() - .absolute() - .left(px(handle_left)) - .top(px((28.0 - handle_size) / 2.0)) - .w(px(handle_size)) - .h(px(handle_size)) - .rounded_full() - .bg(gpui::rgb(0xe4e4ec)), - ) - .child( - div() - .text_xs() - .text_color(gpui::rgb(0xb0b0c0)) - .child(format!("{cur:.1}")), - ), - ) + el.child(render_slider(cx, id, x, y, w, min, max, cur)) } - WidgetCommand::TextInput { id, x, y, w } => { + WidgetCommand::TextInput { + id, + x, + y, + w, + placeholder, + } => { let value = widget_states_snapshot .get(&id) .and_then(|v| match v { @@ -4142,60 +4065,90 @@ impl Render for OxideBrowserView { _ => None, }) .unwrap_or_default(); - let show_caret = text_input_focus_id == Some(id) && caret_blink_on; - el.child( - div() - .id(("oxide_ti", id as usize)) - .absolute() - .left(px(x)) - .top(px(y)) - .w(px(w)) - .h(px(28.0)) - .px_2() - .rounded_md() - .bg(gpui::rgb(0x121218)) - .border_1() - .border_color(if text_input_focus_id == Some(id) { - gpui::rgb(0x6a6a8a) - } else { - gpui::rgb(0x3a3a48) - }) - .cursor_pointer() - .flex() - .flex_row() - .items_center() - .justify_start() - .gap_1() - .min_w_0() - .child( - div() - .flex_initial() - .min_w_0() - .overflow_hidden() - .text_sm() - .text_color(gpui::rgb(0xe4e4ec)) - .child(SharedString::from(value)), - ) - .when(show_caret, |d| { - d.child( - div() - .flex_shrink_0() - .w(px(2.0)) - .h(px(16.0)) - .mt(px(1.0)) - .rounded_sm() - .bg(gpui::rgb(0xe8e8f0)), - ) - }) - .on_click(cx.listener( - move |this, _: &ClickEvent, window, cx| { - this.tabs[this.active_tab].text_input_focus = Some(id); - this.canvas_focus.focus(window); - cx.notify(); - }, - )), - ) + let edit = widget_edits_snapshot.get(&id).cloned().unwrap_or_default(); + let bounds_ref = widget_bounds_cache_snapshot + .get(&id) + .cloned() + .unwrap_or_else(|| Arc::new(Mutex::new(Bounds::default()))); + el.child(render_text_input( + cx, + id, + x, + y, + w, + placeholder, + value, + edit, + text_input_focus_id == Some(id), + caret_blink_on, + bounds_ref, + )) } + WidgetCommand::Textarea { + id, + x, + y, + w, + h, + placeholder, + } => { + let value = widget_states_snapshot + .get(&id) + .and_then(|v| match v { + WidgetValue::Text(t) => Some(t.clone()), + _ => None, + }) + .unwrap_or_default(); + let edit = widget_edits_snapshot.get(&id).cloned().unwrap_or_default(); + let bounds_ref = widget_bounds_cache_snapshot + .get(&id) + .cloned() + .unwrap_or_else(|| Arc::new(Mutex::new(Bounds::default()))); + el.child(render_textarea( + cx, + id, + x, + y, + w, + h, + placeholder, + value, + edit, + text_input_focus_id == Some(id), + caret_blink_on, + bounds_ref, + )) + } + WidgetCommand::Card { + x, + y, + w, + h, + title, + description, + } => el.child(render_card(x, y, w, h, &title, &description)), + WidgetCommand::Badge { + x, + y, + label, + variant, + } => el.child(render_badge(x, y, &label, variant)), + WidgetCommand::Separator { + x, + y, + length, + vertical, + } => el.child(render_separator(x, y, length, vertical)), + WidgetCommand::Progress { x, y, w, value } => { + el.child(render_progress(x, y, w, value)) + } + WidgetCommand::Label { + x, + y, + text, + muted, + size, + } => el.child(render_label(x, y, &text, muted, size)), }); let viewport_h = { @@ -4788,22 +4741,1309 @@ fn phase_color_for(p: ForgePhase) -> Rgba { } } +/// Byte offset of the start of the previous UTF-8 char from `cursor`. +fn prev_char_boundary(text: &str, cursor: usize) -> usize { + if cursor == 0 { + return 0; + } + let mut i = cursor - 1; + while i > 0 && !text.is_char_boundary(i) { + i -= 1; + } + i +} + +/// Byte offset of the start of the next UTF-8 char from `cursor`. +fn next_char_boundary(text: &str, cursor: usize) -> usize { + if cursor >= text.len() { + return text.len(); + } + let mut i = cursor + 1; + while i < text.len() && !text.is_char_boundary(i) { + i += 1; + } + i +} + +/// Byte offset of the start of the current line. +fn line_start(text: &str, cursor: usize) -> usize { + text[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0) +} + +/// Byte offset of the end (just before '\n' or text end) of the current line. +fn line_end(text: &str, cursor: usize) -> usize { + let from = cursor.min(text.len()); + text[from..] + .find('\n') + .map(|i| from + i) + .unwrap_or(text.len()) +} + +/// True if any `WidgetCommand::Textarea` in the current frame has the given id. +fn is_textarea(commands: &[WidgetCommand], id: u32) -> bool { + commands + .iter() + .any(|c| matches!(c, WidgetCommand::Textarea { id: cid, .. } if *cid == id)) +} + +/// Apply a keystroke to the focused widget's text + edit state. +/// +/// Mirrors the URL bar editing surface: arrow keys, home/end, selection with shift, +/// backspace/delete, copy/paste/cut/select-all, and (for textareas) up/down + enter. +fn handle_widget_key(view: &mut OxideBrowserView, id: u32, event: &KeyDownEvent) { + let tab = &mut view.tabs[view.active_tab]; + let multi_line = { + let cmds = tab.host_state.widget_commands.lock().unwrap(); + is_textarea(&cmds, id) + }; + + let mut text = tab + .host_state + .widget_states + .lock() + .unwrap() + .get(&id) + .and_then(|v| match v { + WidgetValue::Text(t) => Some(t.clone()), + _ => None, + }) + .unwrap_or_default(); + let mut edit = tab.widget_edits.get(&id).cloned().unwrap_or_default(); + + let shift = event.keystroke.modifiers.shift; + let secondary = event.keystroke.modifiers.secondary(); + + let mut changed_text = false; + let mut changed_edit = false; + + let has_selection = |edit: &WidgetEditState| edit.cursor != edit.sel_start; + let sel_range = |edit: &WidgetEditState| { + let lo = edit.cursor.min(edit.sel_start); + let hi = edit.cursor.max(edit.sel_start); + lo..hi + }; + let delete_selection = |text: &mut String, edit: &mut WidgetEditState| { + if !has_selection(edit) { + return false; + } + let r = sel_range(edit); + text.replace_range(r.clone(), ""); + edit.cursor = r.start; + edit.sel_start = r.start; + true + }; + let insert_at = |text: &mut String, edit: &mut WidgetEditState, ins: &str| { + let _ = delete_selection(text, edit); + text.insert_str(edit.cursor, ins); + edit.cursor += ins.len(); + edit.sel_start = edit.cursor; + }; + + if secondary { + match event.keystroke.key.as_str() { + "a" => { + edit.sel_start = 0; + edit.cursor = text.len(); + changed_edit = true; + } + "c" if has_selection(&edit) => { + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_text(&text[sel_range(&edit)]); + } + } + "x" if has_selection(&edit) => { + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_text(&text[sel_range(&edit)]); + } + let _ = delete_selection(&mut text, &mut edit); + changed_text = true; + changed_edit = true; + } + "v" => { + if let Ok(Ok(pasted)) = arboard::Clipboard::new().map(|mut cb| cb.get_text()) { + let to_insert = if multi_line { + pasted + } else { + pasted.replace('\n', " ") + }; + insert_at(&mut text, &mut edit, &to_insert); + changed_text = true; + changed_edit = true; + } + } + _ => {} + } + } else { + match event.keystroke.key.as_str() { + "left" => { + if shift { + let p = prev_char_boundary(&text, edit.cursor); + edit.select_to(p, text.len()); + } else if has_selection(&edit) { + let lo = sel_range(&edit).start; + edit.move_to(lo, text.len()); + } else { + let p = prev_char_boundary(&text, edit.cursor); + edit.move_to(p, text.len()); + } + changed_edit = true; + } + "right" => { + if shift { + let n = next_char_boundary(&text, edit.cursor); + edit.select_to(n, text.len()); + } else if has_selection(&edit) { + let hi = sel_range(&edit).end; + edit.move_to(hi, text.len()); + } else { + let n = next_char_boundary(&text, edit.cursor); + edit.move_to(n, text.len()); + } + changed_edit = true; + } + "up" if multi_line => { + let ls = line_start(&text, edit.cursor); + let col = edit.cursor - ls; + if ls == 0 { + if shift { + edit.select_to(0, text.len()); + } else { + edit.move_to(0, text.len()); + } + } else { + let prev_end = ls - 1; + let prev_start = line_start(&text, prev_end); + let new_pos = prev_start + col.min(prev_end - prev_start); + if shift { + edit.select_to(new_pos, text.len()); + } else { + edit.move_to(new_pos, text.len()); + } + } + changed_edit = true; + } + "down" if multi_line => { + let ls = line_start(&text, edit.cursor); + let le = line_end(&text, edit.cursor); + let col = edit.cursor - ls; + if le >= text.len() { + let l = text.len(); + if shift { + edit.select_to(l, text.len()); + } else { + edit.move_to(l, text.len()); + } + } else { + let next_start = le + 1; + let next_end = line_end(&text, next_start); + let new_pos = next_start + col.min(next_end - next_start); + if shift { + edit.select_to(new_pos, text.len()); + } else { + edit.move_to(new_pos, text.len()); + } + } + changed_edit = true; + } + "home" => { + let target = if multi_line { + line_start(&text, edit.cursor) + } else { + 0 + }; + if shift { + edit.select_to(target, text.len()); + } else { + edit.move_to(target, text.len()); + } + changed_edit = true; + } + "end" => { + let target = if multi_line { + line_end(&text, edit.cursor) + } else { + text.len() + }; + if shift { + edit.select_to(target, text.len()); + } else { + edit.move_to(target, text.len()); + } + changed_edit = true; + } + "backspace" => { + if has_selection(&edit) { + let _ = delete_selection(&mut text, &mut edit); + changed_text = true; + changed_edit = true; + } else if edit.cursor > 0 { + let p = prev_char_boundary(&text, edit.cursor); + text.replace_range(p..edit.cursor, ""); + edit.cursor = p; + edit.sel_start = p; + changed_text = true; + changed_edit = true; + } + } + "delete" => { + if has_selection(&edit) { + let _ = delete_selection(&mut text, &mut edit); + changed_text = true; + changed_edit = true; + } else if edit.cursor < text.len() { + let n = next_char_boundary(&text, edit.cursor); + text.replace_range(edit.cursor..n, ""); + changed_text = true; + } + } + "enter" if multi_line => { + insert_at(&mut text, &mut edit, "\n"); + changed_text = true; + changed_edit = true; + } + _ => { + if let Some(s) = text_insert_from_keystroke(&event.keystroke) { + insert_at(&mut text, &mut edit, &s); + changed_text = true; + changed_edit = true; + } + } + } + } + + if changed_text { + tab.host_state + .widget_states + .lock() + .unwrap() + .insert(id, WidgetValue::Text(text)); + } + if changed_edit { + tab.widget_edits.insert(id, edit); + } +} + +/// Colour scheme for one variant (bg, fg, border). +fn variant_colors(variant: WidgetVariant) -> (u32, u32, u32) { + match variant { + WidgetVariant::Default => (theme::PRIMARY, theme::PRIMARY_FG, theme::PRIMARY), + WidgetVariant::Secondary => (theme::SURFACE_HOVER, theme::FG, theme::SURFACE_HOVER), + WidgetVariant::Outline => (theme::BG, theme::FG, theme::BORDER_STRONG), + WidgetVariant::Ghost => (theme::BG, theme::FG, theme::BG), + WidgetVariant::Destructive => (theme::DESTRUCTIVE, theme::FG, theme::DESTRUCTIVE), + } +} + +/// Hover-state background tint for a variant. +fn variant_hover_bg(variant: WidgetVariant) -> u32 { + match variant { + WidgetVariant::Default => 0xe4e4e7, + WidgetVariant::Secondary => theme::BORDER_STRONG, + WidgetVariant::Outline | WidgetVariant::Ghost => theme::SURFACE_HOVER, + WidgetVariant::Destructive => 0x991b1b, + } +} + +#[allow(clippy::too_many_arguments)] +fn render_button( + cx: &mut gpui::Context, + id: u32, + x: f32, + y: f32, + w: f32, + h: f32, + label: String, + variant: WidgetVariant, +) -> impl IntoElement { + let (bg, fg, border) = variant_colors(variant); + let hover_bg = variant_hover_bg(variant); + div() + .id(("oxide_btn", id as usize)) + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(w)) + .h(px(h)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .bg(gpui::rgb(bg)) + .border_1() + .border_color(gpui::rgb(border)) + .hover(|s| s.bg(gpui::rgb(hover_bg))) + .cursor_pointer() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(fg)) + .child(label) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.tabs[this.active_tab] + .host_state + .widget_clicked + .lock() + .unwrap() + .insert(id); + cx.notify(); + })) +} + +fn render_checkbox( + cx: &mut gpui::Context, + id: u32, + x: f32, + y: f32, + label: &str, + checked: bool, +) -> impl IntoElement { + let label = label.to_string(); + div() + .id(("oxide_cb", id as usize)) + .absolute() + .left(px(x)) + .top(px(y)) + .h(px(26.0)) + .flex() + .flex_row() + .items_center() + .gap_2() + .cursor_pointer() + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + let mut states = this.tabs[this.active_tab] + .host_state + .widget_states + .lock() + .unwrap(); + let cur = states + .get(&id) + .and_then(|v| match v { + WidgetValue::Bool(b) => Some(*b), + _ => None, + }) + .unwrap_or(false); + states.insert(id, WidgetValue::Bool(!cur)); + cx.notify(); + })) + .child( + div() + .w(px(16.0)) + .h(px(16.0)) + .rounded_sm() + .border_1() + .border_color(gpui::rgb(if checked { + theme::PRIMARY + } else { + theme::BORDER_STRONG + })) + .bg(gpui::rgb(if checked { theme::PRIMARY } else { theme::BG })) + .flex() + .items_center() + .justify_center() + .text_color(gpui::rgb(theme::PRIMARY_FG)) + .text_xs() + .child(if checked { "✓" } else { "" }), + ) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::FG)) + .child(label), + ) +} + +fn render_switch( + cx: &mut gpui::Context, + id: u32, + x: f32, + y: f32, + label: &str, + checked: bool, +) -> impl IntoElement { + let label = label.to_string(); + let track_w = 32.0; + let track_h = 18.0; + let knob = 14.0; + let knob_x = if checked { track_w - knob - 2.0 } else { 2.0 }; + div() + .id(("oxide_sw", id as usize)) + .absolute() + .left(px(x)) + .top(px(y)) + .h(px(24.0)) + .flex() + .flex_row() + .items_center() + .gap_2() + .cursor_pointer() + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + let mut states = this.tabs[this.active_tab] + .host_state + .widget_states + .lock() + .unwrap(); + let cur = states + .get(&id) + .and_then(|v| match v { + WidgetValue::Bool(b) => Some(*b), + _ => None, + }) + .unwrap_or(false); + states.insert(id, WidgetValue::Bool(!cur)); + cx.notify(); + })) + .child( + div() + .relative() + .w(px(track_w)) + .h(px(track_h)) + .rounded_full() + .bg(gpui::rgb(if checked { + theme::PRIMARY + } else { + theme::MUTED + })) + .child( + div() + .absolute() + .top(px((track_h - knob) / 2.0)) + .left(px(knob_x)) + .w(px(knob)) + .h(px(knob)) + .rounded_full() + .bg(gpui::rgb(if checked { + theme::PRIMARY_FG + } else { + theme::FG + })), + ), + ) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::FG)) + .child(label), + ) +} + +#[allow(clippy::too_many_arguments)] +fn render_slider( + cx: &mut gpui::Context, + id: u32, + x: f32, + y: f32, + w: f32, + min: f32, + max: f32, + cur: f32, +) -> impl IntoElement { + let frac = if max > min { + ((cur - min) / (max - min)).clamp(0.0, 1.0) + } else { + 0.0 + }; + let handle_size: f32 = 16.0; + let handle_left = (frac * w - handle_size / 2.0).clamp(0.0, w - handle_size); + div() + .id(("oxide_sl", id as usize)) + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(w)) + .h(px(28.0)) + .flex() + .items_center() + .on_click(cx.listener(move |this, event: &ClickEvent, _, cx| { + if let Some(pos) = event.mouse_position() { + let tab = &mut this.tabs[this.active_tab]; + let (ox, _) = *tab.host_state.canvas_offset.lock().unwrap(); + let lx = f32::from(pos.x) - ox; + let frac = ((lx - x) / w).clamp(0.0, 1.0); + let v = min + frac * (max - min); + tab.host_state + .widget_states + .lock() + .unwrap() + .insert(id, WidgetValue::Float(v)); + cx.notify(); + } + })) + .child( + div() + .absolute() + .left(px(0.0)) + .top(px(12.0)) + .w(px(w)) + .h(px(4.0)) + .rounded_full() + .bg(gpui::rgb(theme::MUTED)), + ) + .child( + div() + .absolute() + .left(px(0.0)) + .top(px(12.0)) + .w(px(frac * w)) + .h(px(4.0)) + .rounded_full() + .bg(gpui::rgb(theme::PRIMARY)), + ) + .child( + div() + .absolute() + .left(px(handle_left)) + .top(px((28.0 - handle_size) / 2.0)) + .w(px(handle_size)) + .h(px(handle_size)) + .rounded_full() + .bg(gpui::rgb(theme::PRIMARY)) + .border_2() + .border_color(gpui::rgb(theme::BG)), + ) +} + +#[allow(clippy::too_many_arguments)] +fn render_text_input( + cx: &mut gpui::Context, + id: u32, + x: f32, + y: f32, + w: f32, + placeholder: String, + value: String, + edit: WidgetEditState, + focused: bool, + caret_blink_on: bool, + bounds_ref: Arc>>, +) -> impl IntoElement { + let value_for_canvas = SharedString::from(value.clone()); + let placeholder_for_canvas = SharedString::from(placeholder.clone()); + let bounds_for_measure = bounds_ref.clone(); + let cursor = edit.cursor; + let sel_start = edit.sel_start; + + div() + .id(("oxide_ti", id as usize)) + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(w)) + .h(px(36.0)) + .px_3() + .py(px(8.0)) + .rounded_md() + .bg(gpui::rgb(theme::BG)) + .border_1() + .border_color(gpui::rgb(if focused { theme::RING } else { theme::BORDER })) + .cursor_text() + .flex() + .flex_row() + .items_center() + .overflow_hidden() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + let tab = &mut this.tabs[this.active_tab]; + tab.text_input_focus = Some(id); + this.canvas_focus.focus(window); + let bounds = *bounds_ref.lock().unwrap(); + let text = SharedString::from( + tab.host_state + .widget_states + .lock() + .unwrap() + .get(&id) + .and_then(|v| match v { + WidgetValue::Text(t) => Some(t.clone()), + _ => None, + }) + .unwrap_or_default(), + ); + let max = text.len(); + if text.is_empty() { + let edit = tab.widget_edits.entry(id).or_default(); + edit.move_to(0, 0); + edit.selecting = true; + } else { + let rel_x = f32::from(event.position.x) - f32::from(bounds.origin.x); + let run = TextRun { + len: text.len(), + font: font(".SystemUIFont"), + color: rgba8(0xfa, 0xfa, 0xfa, 0xff), + background_color: None, + underline: None, + strikethrough: None, + }; + let line = + window + .text_system() + .shape_line(text.clone(), px(14.0), &[run], None); + let idx = line.closest_index_for_x(px(rel_x)); + let edit = tab.widget_edits.entry(id).or_default(); + if event.modifiers.shift { + edit.select_to(idx, max); + } else { + edit.move_to(idx, max); + } + edit.selecting = true; + } + cx.notify(); + }), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |this, _: &MouseUpEvent, _, _cx| { + if let Some(edit) = this.tabs[this.active_tab].widget_edits.get_mut(&id) { + edit.selecting = false; + } + }), + ) + .on_mouse_move( + cx.listener(move |this, event: &gpui::MouseMoveEvent, window, cx| { + let tab = &mut this.tabs[this.active_tab]; + let selecting = tab + .widget_edits + .get(&id) + .map(|e| e.selecting) + .unwrap_or(false); + if !selecting { + return; + } + let text = SharedString::from( + tab.host_state + .widget_states + .lock() + .unwrap() + .get(&id) + .and_then(|v| match v { + WidgetValue::Text(t) => Some(t.clone()), + _ => None, + }) + .unwrap_or_default(), + ); + if text.is_empty() { + return; + } + let max = text.len(); + let bounds = tab + .widget_bounds_cache + .get(&id) + .map(|b| *b.lock().unwrap()) + .unwrap_or_default(); + let rel_x = f32::from(event.position.x) - f32::from(bounds.origin.x); + let run = TextRun { + len: text.len(), + font: font(".SystemUIFont"), + color: rgba8(0xfa, 0xfa, 0xfa, 0xff), + background_color: None, + underline: None, + strikethrough: None, + }; + let line = window + .text_system() + .shape_line(text.clone(), px(14.0), &[run], None); + let idx = line.closest_index_for_x(px(rel_x)); + if let Some(edit) = tab.widget_edits.get_mut(&id) { + edit.select_to(idx, max); + } + cx.notify(); + }), + ) + .child( + canvas( + move |bounds, window, _cx| { + *bounds_for_measure.lock().unwrap() = bounds; + if value_for_canvas.is_empty() { + if placeholder_for_canvas.is_empty() { + return (None, None); + } + let placeholder_run = TextRun { + len: placeholder_for_canvas.len(), + font: font(".SystemUIFont"), + color: rgba8(0x71, 0x71, 0x7a, 0xff), + background_color: None, + underline: None, + strikethrough: None, + }; + let placeholder_line = window.text_system().shape_line( + placeholder_for_canvas.clone(), + px(14.0), + &[placeholder_run], + None, + ); + return (None, Some(placeholder_line)); + } + let run = TextRun { + len: value_for_canvas.len(), + font: font(".SystemUIFont"), + color: rgba8(0xfa, 0xfa, 0xfa, 0xff), + background_color: None, + underline: None, + strikethrough: None, + }; + let line = window.text_system().shape_line( + value_for_canvas.clone(), + px(14.0), + &[run], + None, + ); + (Some(line), None) + }, + { + move |bounds, + state: (Option, Option), + window, + cx| { + let (line_opt, placeholder_opt) = state; + let has_sel = cursor != sel_start; + let sel_lo = cursor.min(sel_start); + let sel_hi = cursor.max(sel_start); + + if let Some(ref line) = line_opt { + if has_sel { + let sx = line.x_for_index(sel_lo); + let ex = line.x_for_index(sel_hi); + window.paint_quad(gpui::fill( + Bounds::from_corners( + point(bounds.origin.x + sx, bounds.origin.y), + point( + bounds.origin.x + ex, + bounds.origin.y + bounds.size.height, + ), + ), + theme::selection(), + )); + } + let _ = line.paint(bounds.origin, bounds.size.height, window, cx); + if focused && !has_sel && caret_blink_on { + let cx_pos = line.x_for_index(cursor); + window.paint_quad(gpui::fill( + Bounds::from_corners( + point(bounds.origin.x + cx_pos, bounds.origin.y), + point( + bounds.origin.x + cx_pos + px(1.5), + bounds.origin.y + bounds.size.height, + ), + ), + rgba8(0xfa, 0xfa, 0xfa, 0xff), + )); + } + } else if let Some(ref placeholder) = placeholder_opt { + let _ = + placeholder.paint(bounds.origin, bounds.size.height, window, cx); + if focused && caret_blink_on { + window.paint_quad(gpui::fill( + Bounds::from_corners( + bounds.origin, + point( + bounds.origin.x + px(1.5), + bounds.origin.y + bounds.size.height, + ), + ), + rgba8(0xfa, 0xfa, 0xfa, 0xff), + )); + } + } else if focused && caret_blink_on { + window.paint_quad(gpui::fill( + Bounds::from_corners( + bounds.origin, + point( + bounds.origin.x + px(1.5), + bounds.origin.y + bounds.size.height, + ), + ), + rgba8(0xfa, 0xfa, 0xfa, 0xff), + )); + } + } + }, + ) + .flex_1() + .h(px(18.0)), + ) +} + +#[allow(clippy::too_many_arguments)] +fn render_textarea( + cx: &mut gpui::Context, + id: u32, + x: f32, + y: f32, + w: f32, + h: f32, + placeholder: String, + value: String, + edit: WidgetEditState, + focused: bool, + caret_blink_on: bool, + bounds_ref: Arc>>, +) -> impl IntoElement { + let value_for_canvas = value.clone(); + let placeholder_for_canvas = SharedString::from(placeholder); + let bounds_for_measure = bounds_ref.clone(); + let cursor = edit.cursor; + let sel_start = edit.sel_start; + let scroll_y = edit.scroll_y; + + div() + .id(("oxide_ta", id as usize)) + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(w)) + .h(px(h)) + .px_3() + .py_2() + .rounded_md() + .bg(gpui::rgb(theme::BG)) + .border_1() + .border_color(gpui::rgb(if focused { theme::RING } else { theme::BORDER })) + .cursor_text() + .overflow_hidden() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + let tab = &mut this.tabs[this.active_tab]; + tab.text_input_focus = Some(id); + this.canvas_focus.focus(window); + let text = tab + .host_state + .widget_states + .lock() + .unwrap() + .get(&id) + .and_then(|v| match v { + WidgetValue::Text(t) => Some(t.clone()), + _ => None, + }) + .unwrap_or_default(); + let bounds = tab + .widget_bounds_cache + .get(&id) + .map(|b| *b.lock().unwrap()) + .unwrap_or_default(); + let scroll_y = tab.widget_edits.get(&id).map(|e| e.scroll_y).unwrap_or(0.0); + let idx = textarea_hit_index( + &text, + f32::from(event.position.x) - f32::from(bounds.origin.x), + f32::from(event.position.y) - f32::from(bounds.origin.y) + scroll_y, + window, + ); + let max = text.len(); + let edit = tab.widget_edits.entry(id).or_default(); + if event.modifiers.shift { + edit.select_to(idx, max); + } else { + edit.move_to(idx, max); + } + edit.selecting = true; + cx.notify(); + }), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |this, _: &MouseUpEvent, _, _cx| { + if let Some(edit) = this.tabs[this.active_tab].widget_edits.get_mut(&id) { + edit.selecting = false; + } + }), + ) + .on_mouse_move( + cx.listener(move |this, event: &gpui::MouseMoveEvent, window, cx| { + let tab = &mut this.tabs[this.active_tab]; + let selecting = tab + .widget_edits + .get(&id) + .map(|e| e.selecting) + .unwrap_or(false); + if !selecting { + return; + } + let text = tab + .host_state + .widget_states + .lock() + .unwrap() + .get(&id) + .and_then(|v| match v { + WidgetValue::Text(t) => Some(t.clone()), + _ => None, + }) + .unwrap_or_default(); + let bounds = tab + .widget_bounds_cache + .get(&id) + .map(|b| *b.lock().unwrap()) + .unwrap_or_default(); + let scroll_y = tab.widget_edits.get(&id).map(|e| e.scroll_y).unwrap_or(0.0); + let idx = textarea_hit_index( + &text, + f32::from(event.position.x) - f32::from(bounds.origin.x), + f32::from(event.position.y) - f32::from(bounds.origin.y) + scroll_y, + window, + ); + let max = text.len(); + if let Some(edit) = tab.widget_edits.get_mut(&id) { + edit.select_to(idx, max); + } + cx.notify(); + }), + ) + .on_scroll_wheel(cx.listener(move |this, event: &ScrollWheelEvent, _, cx| { + let tab = &mut this.tabs[this.active_tab]; + let text = tab + .host_state + .widget_states + .lock() + .unwrap() + .get(&id) + .and_then(|v| match v { + WidgetValue::Text(t) => Some(t.clone()), + _ => None, + }) + .unwrap_or_default(); + let line_count = text.split('\n').count() as f32; + let line_h: f32 = 20.0; + let content_h = (line_count * line_h).max(line_h); + let max_scroll = (content_h - (h - 16.0)).max(0.0); + let dy = match event.delta { + ScrollDelta::Pixels(p) => f32::from(p.y), + ScrollDelta::Lines(l) => l.y * 20.0, + }; + let edit = tab.widget_edits.entry(id).or_default(); + edit.scroll_y = (edit.scroll_y - dy).clamp(0.0, max_scroll); + cx.notify(); + })) + .child( + canvas( + move |bounds, _window, _cx| { + *bounds_for_measure.lock().unwrap() = bounds; + }, + { + move |bounds, _state: (), window, cx| { + let line_h: f32 = 20.0; + let inner_origin = bounds.origin + point(px(0.0), px(-scroll_y)); + let lines: Vec<&str> = if value_for_canvas.is_empty() { + Vec::new() + } else { + value_for_canvas.split('\n').collect() + }; + + if lines.is_empty() && !placeholder_for_canvas.is_empty() { + let run = TextRun { + len: placeholder_for_canvas.len(), + font: font(".SystemUIFont"), + color: rgba8(0x71, 0x71, 0x7a, 0xff), + background_color: None, + underline: None, + strikethrough: None, + }; + let line = window.text_system().shape_line( + placeholder_for_canvas.clone(), + px(14.0), + &[run], + None, + ); + let _ = line.paint(inner_origin, px(line_h), window, cx); + } + + let has_sel = cursor != sel_start; + let sel_lo = cursor.min(sel_start); + let sel_hi = cursor.max(sel_start); + + let mut byte_off = 0usize; + for (i, line_str) in lines.iter().enumerate() { + let line_start = byte_off; + let line_end = byte_off + line_str.len(); + let y_top = inner_origin.y + px(i as f32 * line_h); + + let shaped = if line_str.is_empty() { + None + } else { + let run = TextRun { + len: line_str.len(), + font: font(".SystemUIFont"), + color: rgba8(0xfa, 0xfa, 0xfa, 0xff), + background_color: None, + underline: None, + strikethrough: None, + }; + Some(window.text_system().shape_line( + SharedString::from(line_str.to_string()), + px(14.0), + &[run], + None, + )) + }; + + if has_sel && sel_lo <= line_end && sel_hi >= line_start { + let lo_in_line = + sel_lo.saturating_sub(line_start).min(line_str.len()); + let hi_in_line = + sel_hi.saturating_sub(line_start).min(line_str.len()); + let sx = shaped + .as_ref() + .map(|l| l.x_for_index(lo_in_line)) + .unwrap_or(px(0.0)); + let ex = if sel_hi > line_end { + shaped + .as_ref() + .map(|l| l.x_for_index(line_str.len())) + .unwrap_or(px(0.0)) + + px(6.0) + } else { + shaped + .as_ref() + .map(|l| l.x_for_index(hi_in_line)) + .unwrap_or(px(0.0)) + }; + window.paint_quad(gpui::fill( + Bounds::from_corners( + point(inner_origin.x + sx, y_top), + point(inner_origin.x + ex, y_top + px(line_h)), + ), + theme::selection(), + )); + } + + if let Some(line) = shaped { + let _ = line.paint( + point(inner_origin.x, y_top), + px(line_h), + window, + cx, + ); + } + + if focused + && !has_sel + && caret_blink_on + && cursor >= line_start + && cursor <= line_end + { + let cur_in_line = cursor - line_start; + let cx_pos = match (cur_in_line, line_str.is_empty()) { + (0, true) => px(0.0), + _ => { + if let Some(ref line) = lines.get(i) { + let run = TextRun { + len: line.len(), + font: font(".SystemUIFont"), + color: rgba8(0xfa, 0xfa, 0xfa, 0xff), + background_color: None, + underline: None, + strikethrough: None, + }; + let shape = window.text_system().shape_line( + SharedString::from(line.to_string()), + px(14.0), + &[run], + None, + ); + shape.x_for_index(cur_in_line) + } else { + px(0.0) + } + } + }; + window.paint_quad(gpui::fill( + Bounds::from_corners( + point(inner_origin.x + cx_pos, y_top), + point( + inner_origin.x + cx_pos + px(1.5), + y_top + px(line_h), + ), + ), + rgba8(0xfa, 0xfa, 0xfa, 0xff), + )); + } + + byte_off = line_end + 1; // account for '\n' + } + + if focused && caret_blink_on && lines.is_empty() { + window.paint_quad(gpui::fill( + Bounds::from_corners( + inner_origin, + point(inner_origin.x + px(1.5), inner_origin.y + px(line_h)), + ), + rgba8(0xfa, 0xfa, 0xfa, 0xff), + )); + } + } + }, + ) + .size_full(), + ) +} + +/// Map an (x, y) point inside a textarea to a byte index in `text`. +fn textarea_hit_index(text: &str, x: f32, y: f32, window: &Window) -> usize { + let line_h: f32 = 20.0; + let line_idx = (y / line_h).floor().max(0.0) as usize; + let mut byte_off = 0usize; + for (i, line_str) in text.split('\n').enumerate() { + if i == line_idx { + if line_str.is_empty() { + return byte_off; + } + let run = TextRun { + len: line_str.len(), + font: font(".SystemUIFont"), + color: rgba8(0xfa, 0xfa, 0xfa, 0xff), + background_color: None, + underline: None, + strikethrough: None, + }; + let shape = window.text_system().shape_line( + SharedString::from(line_str.to_string()), + px(14.0), + &[run], + None, + ); + return byte_off + shape.closest_index_for_x(px(x)); + } + byte_off += line_str.len() + 1; + } + // Click past last line: end of text. + text.len() +} + +fn render_card(x: f32, y: f32, w: f32, h: f32, title: &str, description: &str) -> impl IntoElement { + let mut card = div() + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(w)) + .h(px(h)) + .rounded_lg() + .bg(gpui::rgb(theme::SURFACE)) + .border_1() + .border_color(gpui::rgb(theme::BORDER)) + .p_4() + .flex() + .flex_col() + .gap_1(); + if !title.is_empty() { + card = card.child( + div() + .text_color(gpui::rgb(theme::FG)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_size(px(16.0)) + .child(title.to_string()), + ); + } + if !description.is_empty() { + card = card.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::FG_MUTED)) + .child(description.to_string()), + ); + } + card +} + +fn render_badge(x: f32, y: f32, label: &str, variant: WidgetVariant) -> impl IntoElement { + let (bg, fg, border) = match variant { + WidgetVariant::Default => (theme::PRIMARY, theme::PRIMARY_FG, theme::PRIMARY), + WidgetVariant::Secondary => (theme::SURFACE_HOVER, theme::FG, theme::SURFACE_HOVER), + WidgetVariant::Outline => (theme::BG, theme::FG, theme::BORDER_STRONG), + WidgetVariant::Ghost => (theme::BG, theme::FG_MUTED, theme::BG), + WidgetVariant::Destructive => (theme::DESTRUCTIVE, theme::FG, theme::DESTRUCTIVE), + }; + div() + .absolute() + .left(px(x)) + .top(px(y)) + .h(px(22.0)) + .px_2() + .rounded_full() + .bg(gpui::rgb(bg)) + .border_1() + .border_color(gpui::rgb(border)) + .flex() + .items_center() + .justify_center() + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(fg)) + .child(label.to_string()) +} + +fn render_separator(x: f32, y: f32, length: f32, vertical: bool) -> impl IntoElement { + let (w, h) = if vertical { + (1.0, length) + } else { + (length, 1.0) + }; + div() + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(w)) + .h(px(h)) + .bg(gpui::rgb(theme::BORDER)) +} + +fn render_progress(x: f32, y: f32, w: f32, value: f32) -> impl IntoElement { + let fill = value.clamp(0.0, 1.0) * w; + div() + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(w)) + .h(px(8.0)) + .rounded_full() + .bg(gpui::rgb(theme::MUTED)) + .child( + div() + .h(px(8.0)) + .w(px(fill)) + .rounded_full() + .bg(gpui::rgb(theme::PRIMARY)), + ) +} + +fn render_label(x: f32, y: f32, text: &str, muted: bool, size: f32) -> impl IntoElement { + div() + .absolute() + .left(px(x)) + .top(px(y)) + .text_size(px(size)) + .text_color(gpui::rgb(if muted { theme::FG_MUTED } else { theme::FG })) + .font_weight(if muted { + gpui::FontWeight::NORMAL + } else { + gpui::FontWeight::MEDIUM + }) + .child(text.to_string()) +} + /// Guest widget bounds in canvas-local coordinates (must match overlay hit-test skip logic). -fn widget_bounds(cmd: &WidgetCommand) -> (f32, f32, f32, f32) { +/// Returns `None` for purely decorative widgets that should not block click-through. +fn widget_bounds(cmd: &WidgetCommand) -> Option<(f32, f32, f32, f32)> { match cmd { - WidgetCommand::Button { x, y, w, h, .. } => (*x, *y, *w, *h), - WidgetCommand::Checkbox { x, y, .. } => (*x, *y, 220.0, 30.0), - WidgetCommand::Slider { x, y, w, .. } => (*x, *y, *w, 28.0), - WidgetCommand::TextInput { x, y, w, .. } => (*x, *y, *w, 28.0), + WidgetCommand::Button { x, y, w, h, .. } => Some((*x, *y, *w, *h)), + WidgetCommand::Checkbox { x, y, .. } => Some((*x, *y, 220.0, 26.0)), + WidgetCommand::Slider { x, y, w, .. } => Some((*x, *y, *w, 28.0)), + WidgetCommand::TextInput { x, y, w, .. } => Some((*x, *y, *w, 36.0)), + WidgetCommand::Textarea { x, y, w, h, .. } => Some((*x, *y, *w, *h)), + WidgetCommand::Card { x, y, w, h, .. } => Some((*x, *y, *w, *h)), + WidgetCommand::Switch { x, y, .. } => Some((*x, *y, 220.0, 24.0)), + WidgetCommand::Badge { .. } + | WidgetCommand::Separator { .. } + | WidgetCommand::Progress { .. } + | WidgetCommand::Label { .. } => None, } } /// True if `(lx, ly)` lies inside any guest widget rect (canvas space). fn canvas_point_hits_widget(lx: f32, ly: f32, cmds: &[WidgetCommand]) -> bool { for cmd in cmds { - let (x, y, w, h) = widget_bounds(cmd); - if lx >= x && ly >= y && lx <= x + w && ly <= y + h { - return true; + if let Some((x, y, w, h)) = widget_bounds(cmd) { + if lx >= x && ly >= y && lx <= x + w && ly <= y + h { + return true; + } } } false diff --git a/oxide-sdk/src/lib.rs b/oxide-sdk/src/lib.rs index 45bc3fb..15ee2a1 100644 --- a/oxide-sdk/src/lib.rs +++ b/oxide-sdk/src/lib.rs @@ -549,6 +549,7 @@ extern "C" { h: f32, label_ptr: u32, label_len: u32, + variant: u32, ) -> u32; #[link_name = "api_ui_checkbox"] @@ -561,6 +562,10 @@ extern "C" { initial: u32, ) -> u32; + #[link_name = "api_ui_switch"] + fn _api_ui_switch(id: u32, x: f32, y: f32, label_ptr: u32, label_len: u32, initial: u32) + -> u32; + #[link_name = "api_ui_slider"] fn _api_ui_slider(id: u32, x: f32, y: f32, w: f32, min: f32, max: f32, initial: f32) -> f32; @@ -572,10 +577,51 @@ extern "C" { w: f32, init_ptr: u32, init_len: u32, + placeholder_ptr: u32, + placeholder_len: u32, out_ptr: u32, out_cap: u32, ) -> u32; + #[link_name = "api_ui_textarea"] + fn _api_ui_textarea( + id: u32, + x: f32, + y: f32, + w: f32, + h: f32, + init_ptr: u32, + init_len: u32, + placeholder_ptr: u32, + placeholder_len: u32, + out_ptr: u32, + out_cap: u32, + ) -> u32; + + #[link_name = "api_ui_card"] + fn _api_ui_card( + x: f32, + y: f32, + w: f32, + h: f32, + title_ptr: u32, + title_len: u32, + desc_ptr: u32, + desc_len: u32, + ); + + #[link_name = "api_ui_badge"] + fn _api_ui_badge(x: f32, y: f32, label_ptr: u32, label_len: u32, variant: u32); + + #[link_name = "api_ui_separator"] + fn _api_ui_separator(x: f32, y: f32, length: f32, vertical: u32); + + #[link_name = "api_ui_progress"] + fn _api_ui_progress(x: f32, y: f32, w: f32, value: f32); + + #[link_name = "api_ui_label"] + fn _api_ui_label(x: f32, y: f32, text_ptr: u32, text_len: u32, muted: u32, size: f32); + // ── Audio Playback ────────────────────────────────────────────── #[link_name = "api_audio_play"] @@ -3329,13 +3375,70 @@ pub const KEY_PAGE_DOWN: u32 = 49; // ─── Interactive Widget API ───────────────────────────────────────────────── +/// Visual emphasis for buttons and badges, mirroring shadcn/ui variants. +/// +/// Default is a high-contrast filled button; secondary is a muted fill; outline +/// shows only a border; ghost is transparent until hover; destructive flags a +/// dangerous action. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UiVariant { + /// High-emphasis button (light fill on dark theme). + #[default] + Default, + /// Neutral fill on a muted surface. + Secondary, + /// Transparent fill with a visible border. + Outline, + /// Transparent fill, only shows on hover. + Ghost, + /// Red emphasis for destructive actions / errors. + Destructive, +} + +impl UiVariant { + fn as_u32(self) -> u32 { + match self { + Self::Default => 0, + Self::Secondary => 1, + Self::Outline => 2, + Self::Ghost => 3, + Self::Destructive => 4, + } + } +} + /// Render a button at the given position. Returns `true` if it was clicked -/// on the previous frame. +/// on the previous frame. Use [`ui_button_variant`] for non-default styling. /// /// Must be called from `on_frame()` — widgets are only rendered for /// interactive applications that export a frame loop. pub fn ui_button(id: u32, x: f32, y: f32, w: f32, h: f32, label: &str) -> bool { - unsafe { _api_ui_button(id, x, y, w, h, label.as_ptr() as u32, label.len() as u32) != 0 } + ui_button_variant(id, x, y, w, h, label, UiVariant::Default) +} + +/// Render a button with a specific [`UiVariant`]. Returns `true` if it was +/// clicked on the previous frame. +pub fn ui_button_variant( + id: u32, + x: f32, + y: f32, + w: f32, + h: f32, + label: &str, + variant: UiVariant, +) -> bool { + unsafe { + _api_ui_button( + id, + x, + y, + w, + h, + label.as_ptr() as u32, + label.len() as u32, + variant.as_u32(), + ) != 0 + } } /// Render a checkbox. Returns the current checked state. @@ -3354,6 +3457,22 @@ pub fn ui_checkbox(id: u32, x: f32, y: f32, label: &str, initial: bool) -> bool } } +/// Render a pill-shaped on/off toggle. Returns the current checked state. +/// +/// `initial` sets the value the first time this ID is seen. +pub fn ui_switch(id: u32, x: f32, y: f32, label: &str, initial: bool) -> bool { + unsafe { + _api_ui_switch( + id, + x, + y, + label.as_ptr() as u32, + label.len() as u32, + if initial { 1 } else { 0 }, + ) != 0 + } +} + /// Render a slider. Returns the current value. /// /// `initial` sets the value the first time this ID is seen. @@ -3363,8 +3482,26 @@ pub fn ui_slider(id: u32, x: f32, y: f32, w: f32, min: f32, max: f32, initial: f /// Render a single-line text input. Returns the current text content. /// -/// `initial` sets the text the first time this ID is seen. -pub fn ui_text_input(id: u32, x: f32, y: f32, w: f32, initial: &str) -> String { +/// `placeholder` is the muted hint shown when the field is empty. The text +/// content persists across frames; use [`ui_text_input_with_value`] to seed +/// an initial value the first time this `id` is seen. +/// +/// Supports caret movement (←/→/Home/End), selection (Shift+arrows), +/// copy/cut/paste (Cmd/Ctrl+C/X/V), and select-all (Cmd/Ctrl+A). +pub fn ui_text_input(id: u32, x: f32, y: f32, w: f32, placeholder: &str) -> String { + ui_text_input_with_value(id, x, y, w, placeholder, "") +} + +/// Like [`ui_text_input`], but seeds the field with `initial` the first time +/// this `id` is seen. Subsequent frames use the user-edited value. +pub fn ui_text_input_with_value( + id: u32, + x: f32, + y: f32, + w: f32, + placeholder: &str, + initial: &str, +) -> String { let mut buf = [0u8; 4096]; let len = unsafe { _api_ui_text_input( @@ -3374,6 +3511,47 @@ pub fn ui_text_input(id: u32, x: f32, y: f32, w: f32, initial: &str) -> String { w, initial.as_ptr() as u32, initial.len() as u32, + placeholder.as_ptr() as u32, + placeholder.len() as u32, + buf.as_mut_ptr() as u32, + buf.len() as u32, + ) + }; + String::from_utf8_lossy(&buf[..len as usize]).to_string() +} + +/// Render a multi-line text input. Returns the current text content. +/// +/// Supports cursor navigation (←/→/↑/↓/Home/End), selection (Shift+arrows), +/// copy/cut/paste, select-all, scrolling, and `Enter` to insert a newline. +/// Use [`ui_textarea_with_value`] to seed an initial value. +pub fn ui_textarea(id: u32, x: f32, y: f32, w: f32, h: f32, placeholder: &str) -> String { + ui_textarea_with_value(id, x, y, w, h, placeholder, "") +} + +/// Like [`ui_textarea`], but seeds the field with `initial` the first time +/// this `id` is seen. +pub fn ui_textarea_with_value( + id: u32, + x: f32, + y: f32, + w: f32, + h: f32, + placeholder: &str, + initial: &str, +) -> String { + let mut buf = [0u8; 16384]; + let len = unsafe { + _api_ui_textarea( + id, + x, + y, + w, + h, + initial.as_ptr() as u32, + initial.len() as u32, + placeholder.as_ptr() as u32, + placeholder.len() as u32, buf.as_mut_ptr() as u32, buf.len() as u32, ) @@ -3381,6 +3559,66 @@ pub fn ui_text_input(id: u32, x: f32, y: f32, w: f32, initial: &str) -> String { String::from_utf8_lossy(&buf[..len as usize]).to_string() } +/// Render a card container with an optional title and description. +/// +/// Cards are purely visual containers — they do not capture clicks. Pass an +/// empty string to omit either text element. +pub fn ui_card(x: f32, y: f32, w: f32, h: f32, title: &str, description: &str) { + unsafe { + _api_ui_card( + x, + y, + w, + h, + title.as_ptr() as u32, + title.len() as u32, + description.as_ptr() as u32, + description.len() as u32, + ); + } +} + +/// Render a small status pill at the given position. +pub fn ui_badge(x: f32, y: f32, label: &str, variant: UiVariant) { + unsafe { + _api_ui_badge( + x, + y, + label.as_ptr() as u32, + label.len() as u32, + variant.as_u32(), + ); + } +} + +/// Render a 1px horizontal divider of the given width. +pub fn ui_separator(x: f32, y: f32, length: f32) { + unsafe { _api_ui_separator(x, y, length, 0) } +} + +/// Render a 1px vertical divider of the given height. +pub fn ui_separator_vertical(x: f32, y: f32, length: f32) { + unsafe { _api_ui_separator(x, y, length, 1) } +} + +/// Render a progress bar; `value` is clamped to `0.0..=1.0`. +pub fn ui_progress(x: f32, y: f32, w: f32, value: f32) { + unsafe { _api_ui_progress(x, y, w, value) } +} + +/// Render a static text label using GPU font shaping. +/// +/// `muted` switches to a lower-emphasis colour suitable for hints/captions; +/// `size` is the font size in CSS-like px (use `14.0` for body text). +pub fn ui_label(x: f32, y: f32, text: &str, size: f32) { + unsafe { _api_ui_label(x, y, text.as_ptr() as u32, text.len() as u32, 0, size) } +} + +/// Render a muted (caption) variant of [`ui_label`]. +pub fn ui_label_muted(x: f32, y: f32, text: &str, size: f32) { + unsafe { _api_ui_label(x, y, text.as_ptr() as u32, text.len() as u32, 1, size) } +} + // ─── Download & Print-to-PDF API ───────────────────────────────────────────── /// Save arbitrary bytes as a file in the host Downloads directory. From 0fb40744198c96d3488dc82ad585e60362cf897e Mon Sep 17 00:00:00 2001 From: Nikhil Ranjan Date: Sun, 24 May 2026 12:47:40 +0200 Subject: [PATCH 2/2] parallax --- oxide-landing/script.js | 103 +++++++++ oxide-landing/styles.css | 467 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 570 insertions(+) diff --git a/oxide-landing/script.js b/oxide-landing/script.js index 22ac611..383aa18 100644 --- a/oxide-landing/script.js +++ b/oxide-landing/script.js @@ -6,6 +6,9 @@ document.addEventListener('DOMContentLoaded', () => { initMobileMenu(); initVideoPlay(); initForgePipeline(); + initScrollProgress(); + initParallax(); + initTilt(); }); /* ─── Particle Background ─── */ @@ -460,6 +463,106 @@ function initForgePipeline() { }); } +/* ─── Scroll Progress Bar ─── */ +function initScrollProgress() { + const bar = document.createElement('div'); + bar.className = 'scroll-progress'; + document.body.appendChild(bar); + + let ticking = false; + function update() { + const doc = document.documentElement; + const max = (doc.scrollHeight - doc.clientHeight) || 1; + const pct = Math.min(100, Math.max(0, (window.scrollY / max) * 100)); + bar.style.width = pct + '%'; + ticking = false; + } + + window.addEventListener('scroll', () => { + if (!ticking) { + requestAnimationFrame(update); + ticking = true; + } + }, { passive: true }); + + update(); +} + +/* ─── Parallax (translateY on scroll) ─── */ +function initParallax() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const targets = [ + { el: document.querySelector('.hero-glow'), speed: 0.35 }, + { el: document.querySelector('.token-glow'), speed: 0.25 }, + { el: document.querySelector('#particles-canvas'), speed: 0.08 }, + ].filter(t => t.el); + + if (!targets.length) return; + + let ticking = false; + function update() { + const y = window.scrollY; + targets.forEach(t => { + t.el.style.transform = (t.el === document.querySelector('.hero-glow') || + t.el === document.querySelector('.token-glow')) + ? `translateX(-50%) translateY(${y * t.speed}px)` + : `translate3d(0, ${y * t.speed}px, 0)`; + }); + ticking = false; + } + + window.addEventListener('scroll', () => { + if (!ticking) { + requestAnimationFrame(update); + ticking = true; + } + }, { passive: true }); + + update(); +} + +/* ─── 3D Tilt on the demo browser preview ─── */ +function initTilt() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + if (window.matchMedia('(hover: none)').matches) return; + + const card = document.querySelector('.browser-preview'); + if (!card) return; + + card.style.transition = 'transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.4s ease'; + card.style.willChange = 'transform'; + + const MAX = 6; + let raf = 0; + let targetX = 0, targetY = 0, curX = 0, curY = 0; + + function loop() { + curX += (targetX - curX) * 0.12; + curY += (targetY - curY) * 0.12; + card.style.transform = `perspective(1200px) rotateX(${curY}deg) rotateY(${curX}deg)`; + if (Math.abs(targetX - curX) > 0.01 || Math.abs(targetY - curY) > 0.01) { + raf = requestAnimationFrame(loop); + } else { + raf = 0; + } + } + + card.addEventListener('mousemove', (e) => { + const r = card.getBoundingClientRect(); + const px = (e.clientX - r.left) / r.width - 0.5; + const py = (e.clientY - r.top) / r.height - 0.5; + targetX = px * MAX * 2; + targetY = -py * MAX * 2; + if (!raf) raf = requestAnimationFrame(loop); + }); + + card.addEventListener('mouseleave', () => { + targetX = 0; targetY = 0; + if (!raf) raf = requestAnimationFrame(loop); + }); +} + /* ─── Mobile Menu ─── */ function initMobileMenu() { const toggle = document.getElementById('mobile-toggle'); diff --git a/oxide-landing/styles.css b/oxide-landing/styles.css index 529bb75..a09a5ce 100644 --- a/oxide-landing/styles.css +++ b/oxide-landing/styles.css @@ -1902,3 +1902,470 @@ a.about-card--link { justify-content: center; } } + +/* ═══════════════════════════════════════════════════════════════ + ENHANCED ANIMATION LAYER + Adds: floating, bouncing, animated gradients, scroll parallax + targets, shimmer, glow, scale/rotate hover, scroll progress. + ═══════════════════════════════════════════════════════════════ */ + +/* ─── Keyframes ─── */ +@keyframes float-y { + 0%, 100% { transform: translate3d(0, 0, 0); } + 50% { transform: translate3d(0, -10px, 0); } +} + +@keyframes float-xy { + 0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); } + 33% { transform: translate3d(6px, -8px, 0) rotate(2deg); } + 66% { transform: translate3d(-4px, -4px, 0) rotate(-2deg); } +} + +@keyframes bounce-soft { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } +} + +@keyframes spin-slow { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes gradient-pan { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes aurora-drift { + 0% { transform: translate3d(-10%, -10%, 0) rotate(0deg); } + 50% { transform: translate3d( 10%, 10%, 0) rotate(180deg); } + 100% { transform: translate3d(-10%, -10%, 0) rotate(360deg); } +} + +@keyframes glow-breathe { + 0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); } + 50% { opacity: 0.85; transform: translateX(-50%) scale(1.08); } +} + +@keyframes shimmer-sweep { + 0% { transform: translateX(-120%) skewX(-20deg); } + 100% { transform: translateX(220%) skewX(-20deg); } +} + +@keyframes text-glow { + 0%, 100% { text-shadow: 0 0 0 transparent; } + 50% { text-shadow: 0 0 24px rgba(162, 155, 254, 0.45), + 0 0 48px rgba(0, 210, 255, 0.25); } +} + +@keyframes badge-pulse-ring { + 0% { box-shadow: 0 0 0 0 rgba(0, 245, 160, 0.6); } + 70% { box-shadow: 0 0 0 10px rgba(0, 245, 160, 0); } + 100% { box-shadow: 0 0 0 0 rgba(0, 245, 160, 0); } +} + +/* ─── Animated aurora behind everything ─── */ +body::before, +body::after { + content: ''; + position: fixed; + inset: -25%; + z-index: 0; + pointer-events: none; + will-change: transform; +} + +body::before { + background: + radial-gradient(40% 50% at 20% 25%, rgba(108, 92, 231, 0.22), transparent 60%), + radial-gradient(35% 45% at 80% 30%, rgba(0, 210, 255, 0.18), transparent 60%), + radial-gradient(45% 55% at 50% 85%, rgba(0, 245, 160, 0.14), transparent 60%); + filter: blur(40px); + animation: aurora-drift 32s linear infinite; + opacity: 0.55; +} + +body::after { + background: linear-gradient( + 135deg, + rgba(108, 92, 231, 0.04) 0%, + rgba(0, 210, 255, 0.04) 25%, + rgba(0, 245, 160, 0.04) 50%, + rgba(108, 92, 231, 0.04) 75%, + rgba(0, 210, 255, 0.04) 100%); + background-size: 400% 400%; + animation: gradient-pan 24s ease infinite; + mix-blend-mode: screen; + opacity: 0.9; +} + +/* Keep navbar / sections above the aurora */ +.navbar, +.hero, +.demo, +.forge, +.about, +.features, +.architecture, +.roadmap, +.token, +.community, +.footer { + position: relative; + z-index: 1; +} + +/* ─── Floating micro-animations ─── */ +.logo-img { + animation: float-y 5s ease-in-out infinite; +} + +.hero-badge { + animation: float-y 6s ease-in-out infinite; + position: relative; + overflow: hidden; +} + +.hero-badge::after { + content: ''; + position: absolute; + top: 0; left: 0; + width: 40%; + height: 100%; + background: linear-gradient( + 110deg, + transparent 20%, + rgba(255, 255, 255, 0.18) 50%, + transparent 80%); + animation: shimmer-sweep 3.5s ease-in-out infinite; + animation-delay: 1.2s; + pointer-events: none; +} + +.badge-dot { + animation: pulse-dot 2s ease-in-out infinite, + badge-pulse-ring 2.2s ease-out infinite; +} + +.about-icon, +.reward-icon, +.community-icon { + transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.4s ease, + background 0.4s ease; +} + +.about-card:hover .about-icon, +.reward-card:hover .reward-icon { + transform: scale(1.12) rotate(-8deg); + box-shadow: 0 8px 28px var(--accent-glow); +} + +.community-card:hover .community-icon { + transform: scale(1.1) rotate(6deg); + box-shadow: 0 8px 28px rgba(162, 155, 254, 0.25); +} + +.community-arrow { + display: inline-block; +} + +.community-card:hover .community-arrow { + animation: bounce-soft 0.9s ease-in-out infinite; +} + +/* ─── Animated gradient text ─── */ +.gradient-text, +.stat-value, +.security-value, +.tech-name { + background-size: 200% 200%; + animation: gradient-pan 8s ease infinite; +} + +.tech-name { + background: linear-gradient(135deg, var(--accent-secondary), var(--accent-cyan)); + background-size: 200% 200%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero h1 .gradient-text { + display: inline-block; + animation: gradient-pan 8s ease infinite, text-glow 6s ease-in-out infinite; +} + +.section-tag { + position: relative; + display: inline-block; +} + +.section-tag::after { + content: ''; + position: absolute; + left: 0; right: 0; bottom: -6px; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); + transform: scaleX(0.4); + transform-origin: center; + transition: transform 0.6s ease; +} + +.section-header:hover .section-tag::after { + transform: scaleX(1); +} + +/* ─── Hero glow: breathe + soft drift via parallax ─── */ +.hero-glow { + animation: glow-breathe 8s ease-in-out infinite; + will-change: transform, opacity; +} + +.token-glow { + animation: glow-breathe 10s ease-in-out infinite; + will-change: transform, opacity; +} + +/* ─── Enhanced button hover: glow + scale + sheen ─── */ +.btn { + position: relative; + overflow: hidden; + will-change: transform, box-shadow; +} + +.btn::after { + content: ''; + position: absolute; + top: 0; left: 0; + width: 40%; + height: 100%; + background: linear-gradient( + 110deg, + transparent 0%, + rgba(255, 255, 255, 0.18) 50%, + transparent 100%); + transform: translateX(-150%) skewX(-20deg); + transition: transform 0.7s ease; + pointer-events: none; +} + +.btn:hover::after { + transform: translateX(220%) skewX(-20deg); +} + +.btn-primary { + background: var(--gradient-primary); + background-size: 180% 180%; + background-position: 0% 50%; + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.3s ease, + background-position 0.6s ease; +} + +.btn-primary:hover { + transform: translateY(-3px) scale(1.04); + background-position: 100% 50%; + box-shadow: 0 12px 40px var(--accent-glow), + 0 0 60px rgba(0, 210, 255, 0.22); +} + +.btn-primary:active { + transform: translateY(-1px) scale(1.0); +} + +.btn-outline { + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), + border-color 0.3s ease, + box-shadow 0.3s ease, + background 0.3s ease; +} + +.btn-outline:hover { + transform: translateY(-3px) scale(1.03); + box-shadow: 0 10px 30px rgba(108, 92, 231, 0.18), + inset 0 0 0 1px rgba(162, 155, 254, 0.4); +} + +.copy-btn { + transition: transform 0.25s ease, background 0.2s ease, + color 0.2s ease, border-color 0.2s ease, + box-shadow 0.25s ease; +} + +.copy-btn:hover { + transform: translateY(-1px) scale(1.04); + box-shadow: 0 6px 18px rgba(108, 92, 231, 0.18); +} + +/* ─── Card hover: glow border + lift + scale ─── */ +.about-card, +.tech-card, +.community-card, +.reward-card, +.phase-content { + position: relative; + will-change: transform, box-shadow; + transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), + border-color 0.3s ease, + box-shadow 0.4s ease, + background 0.3s ease; +} + +.about-card::before, +.tech-card::before, +.community-card::before, +.reward-card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient(135deg, + rgba(108, 92, 231, 0), + rgba(0, 210, 255, 0) 30%, + rgba(108, 92, 231, 0.55) 50%, + rgba(0, 210, 255, 0) 70%, + rgba(108, 92, 231, 0)); + background-size: 200% 200%; + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 0.4s ease; + pointer-events: none; +} + +.about-card:hover::before, +.tech-card:hover::before, +.community-card:hover::before, +.reward-card:hover::before { + opacity: 1; + animation: gradient-pan 3s ease infinite; +} + +.about-card:hover { + transform: translateY(-6px) scale(1.015); + box-shadow: 0 20px 60px rgba(108, 92, 231, 0.18), + 0 0 1px rgba(162, 155, 254, 0.4); +} + +.tech-card:hover { + transform: translateY(-4px) scale(1.02); + box-shadow: 0 14px 40px rgba(108, 92, 231, 0.18); +} + +.community-card:hover { + transform: translateY(-6px) scale(1.02); + box-shadow: 0 20px 50px rgba(162, 155, 254, 0.18); +} + +.reward-card:hover { + transform: translateY(-3px) scale(1.02); + box-shadow: 0 10px 30px rgba(108, 92, 231, 0.18); +} + +.phase-content:hover { + transform: translateX(6px) scale(1.005); + box-shadow: 0 14px 40px rgba(108, 92, 231, 0.12); +} + +/* Browser preview: gentle tilt on hover */ +.browser-preview { + transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.6s ease; +} + +.browser-preview:hover { + transform: translateY(-4px) scale(1.005); + box-shadow: + 0 0 0 1px rgba(108, 92, 231, 0.2), + 0 30px 100px rgba(0, 0, 0, 0.55), + 0 0 160px rgba(108, 92, 231, 0.14); +} + +/* Nav link underline shimmer */ +.nav-links a { + position: relative; +} + +.nav-links a::after { + content: ''; + position: absolute; + left: 0; right: 0; bottom: -4px; + height: 1px; + background: linear-gradient(90deg, var(--accent-secondary), var(--accent-cyan)); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.35s ease; +} + +.nav-links a:hover::after { + transform: scaleX(1); +} + +/* ─── Scroll-progress bar (created by JS, styled here) ─── */ +.scroll-progress { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 0%; + background: linear-gradient(90deg, + var(--accent-primary), + var(--accent-cyan), + var(--accent-green)); + background-size: 200% 100%; + box-shadow: 0 0 12px rgba(0, 210, 255, 0.6); + z-index: 1100; + pointer-events: none; + animation: gradient-pan 6s ease infinite; + transition: width 0.08s linear; +} + +/* ─── Fade-up: stronger easing + slight scale ─── */ +[data-animate] { + transform: translateY(36px) scale(0.985); + transition: opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.7s cubic-bezier(0.22, 1, 0.36, 1); +} + +[data-animate].visible { + transform: translateY(0) scale(1); +} + +/* ─── Reduced motion: kill the heavy stuff ─── */ +@media (prefers-reduced-motion: reduce) { + body::before, + body::after, + .logo-img, + .hero-badge, + .hero-badge::after, + .badge-dot, + .hero-glow, + .token-glow, + .gradient-text, + .stat-value, + .security-value, + .tech-name, + .hero h1 .gradient-text, + .scroll-progress, + .btn-primary, + .about-card::before, + .tech-card::before, + .community-card::before, + .reward-card::before { + animation: none !important; + } + + .btn::after, + .hero-badge::after { + display: none; + } +}