Skip to content

feat(ui): shadcn/ui-inspired component kit — buttons, inputs, switches#47

Open
niklabh wants to merge 1 commit into
mainfrom
feat/shadcn-ui-component-kit
Open

feat(ui): shadcn/ui-inspired component kit — buttons, inputs, switches#47
niklabh wants to merge 1 commit into
mainfrom
feat/shadcn-ui-component-kit

Conversation

@niklabh
Copy link
Copy Markdown
Owner

@niklabh niklabh commented May 20, 2026

... ,cards, badges, separators, progress, labels

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

Summary by CodeRabbit

Release Notes

  • New Features
    • Added shadcn-demo example application
    • Expanded UI widget library with Switch, Card, Badge, Separator, Progress, and Label components
    • Enhanced text inputs and textareas with placeholder text and initial value support
    • Added button style variants for visual emphasis

Review Change Stack

…s, cards, badges, separators, progress, labels

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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

📝 Walkthrough

Walkthrough

This PR expands the oxide UI widget system with visual variants, new interactive controls (switches, textareas, cards, badges, separators, progress bars, labels), placeholder-aware text inputs, and a comprehensive shadcn-ui-inspired demo showcasing the complete component library with theme support, edit-state tracking, and keyboard interaction.

Changes

Widget System Expansion

Layer / File(s) Summary
Host widget contract and Wasm bindings
oxide-browser/src/capabilities.rs
WidgetVariant enum represents visual emphasis styles (Default, Secondary, Destructive, Outline). WidgetCommand is extended with Button.variant, TextInput.placeholder, and new variants for Textarea, Switch, Card, Badge, Separator, Progress, and Label. Wasm host imports (api_ui_button, api_ui_text_input, api_ui_textarea, api_ui_switch, api_ui_card, api_ui_badge, api_ui_separator, api_ui_progress, api_ui_label) are registered and map guest-provided variant flags and placeholder text to WidgetCommand variants.
Guest SDK wrapper functions
oxide-sdk/src/lib.rs
Guest SDK adds UiVariant enum with as_u32() and new functions: ui_button_variant() for styled buttons (existing ui_button() now delegates with Default variant), ui_switch() for toggles, refactored ui_text_input() and ui_textarea() to accept placeholder, new _with_value() variants for seeding initial content, plus wrappers for ui_card(), ui_badge(), ui_separator() / ui_separator_vertical(), ui_progress(), and ui_label() / ui_label_muted().
Browser UI theme, state, and rendering
oxide-browser/src/ui.rs
Adds theme module with shared palette constants. Introduces WidgetEditState per-widget (cursor, selection, scroll offset) persisted in TabState alongside widget_bounds_cache for hit-testing. Keyboard event handler handle_widget_key() manages cursor movement, selection, deletion, clipboard operations, and multiline navigation for focused editable widgets. Canvas overlay rendering is refactored into render_* helper functions (render_button, render_text_input, render_textarea, render_switch, render_checkbox, render_card, render_badge, render_separator, render_progress, render_label) that receive per-widget edit state, caret parameters, and theme colors. Hit-testing updated to allow click-through for non-interactive widgets.
shadcn-demo showcase application
examples/shadcn-demo/Cargo.toml, examples/shadcn-demo/src/lib.rs
New Wasm demo library (cdylib) with start_app() and on_frame() FFI functions. Renders a complete shadcn/ui-inspired layout (labels, separators, buttons with variants, badges, form inputs, live preview card, sliders, progress bar, tip) with live state binding from text input, textarea, and checkbox/switch widgets into a "Live preview" card.
Workspace and example updates
Cargo.toml, examples/hello-oxide/src/lib.rs
Registers examples/shadcn-demo as a workspace member. Updates hello-oxide text input initial value from empty string to "Type your name…".

Sequence Diagram

sequenceDiagram
    participant GuestApp as Guest App
    participant SDK as oxide-sdk
    participant HostRuntime as oxide-browser Runtime
    participant WidgetState as Widget State
    participant Renderer as Canvas Renderer
    
    GuestApp->>SDK: ui_text_input(id, x, y, w, placeholder)
    SDK->>HostRuntime: _api_ui_text_input(ptr, len, placeholder_ptr, len, output_buf)
    HostRuntime->>WidgetState: enqueue WidgetCommand::TextInput { placeholder }
    HostRuntime->>WidgetState: initialize WidgetEditState if new id
    HostRuntime->>HostRuntime: cache bounds for hit-testing
    
    GuestApp->>HostRuntime: key event (focused widget)
    HostRuntime->>WidgetState: handle_widget_key updates cursor, text
    HostRuntime->>WidgetState: update widget_edits state
    
    HostRuntime->>Renderer: snapshot widget_edits, widget_bounds_cache
    Renderer->>Renderer: render_text_input with edit state
    Renderer->>Renderer: draw text, selection, caret
    Renderer->>GuestApp: return rendered frame
    
    GuestApp->>SDK: read current widget text value
    HostRuntime->>GuestApp: return persisted text from widget_states
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

  • niklabh/oxide#41: Expands TextArea widget support across the guest SDK (oxide-sdk/src/lib.rs) and host bindings (oxide-browser/src/capabilities.rs), sharing the same multi-line text editing architecture introduced in this PR.
  • niklabh/oxide#31: Extends register_host_functions in oxide-browser/src/capabilities.rs with WebRTC host import bindings, using the same Wasm binding pattern this PR applies to widget APIs.

Poem

🐰 Widget layouts bloom from code,
Placeholders guide the user's road,
With variants styled, each button gleams,
And text edits flow like flowing streams!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main addition: a shadcn/ui-inspired component kit with buttons, inputs, and switches as core features.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shadcn-ui-component-kit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
oxide-browser/src/capabilities.rs (1)

3964-4011: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Expose the full text length from text widgets.

api_ui_text_input and api_ui_textarea currently return min(text.len(), out_cap). Once the widget content exceeds the caller’s buffer, the guest cannot tell “full value” from “truncated value”, so SDK callers silently lose everything past their fixed buffers. Return the required length, or a retry sentinel, so callers can resize and read the complete value.

Also applies to: 4013-4062

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@oxide-browser/src/capabilities.rs` around lines 3964 - 4011,
api_ui_text_input currently writes up to out_cap bytes but returns write_len,
hiding whether the string was truncated; change the handler to still write
min(bytes.len(), out_cap) via write_guest_bytes (using out_ptr/out_cap) but
return the full required length (bytes.len() as u32) so the guest can detect
truncation and resize; apply the identical change to api_ui_textarea where
WidgetValue::Text is read/written, and ensure you keep using read_guest_string,
write_guest_bytes, out_ptr and out_cap as in the current implementation while
only changing the returned value.
oxide-sdk/src/lib.rs (1)

3491-3521: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep ui_text_input and ui_textarea source-compatible.

These wrappers changed the existing string parameter from “initial value” to “placeholder” without changing the function names. Existing guest code will still compile, but prefilled fields will now come up empty after upgrade. Preserve the old behavior under these names and add placeholder-specific helpers instead.

Also applies to: 3523-3560

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@oxide-sdk/src/lib.rs` around lines 3491 - 3521, The wrappers accidentally
changed the semantics of ui_text_input and ui_textarea to treat their string
argument as a placeholder instead of the original initial value; restore
backward compatibility by making ui_text_input and ui_textarea keep the original
"initial value" behavior (i.e., forward their string argument as the initial
value to the underlying implementation or to
ui_text_input_with_value/ui_textarea_with_value), and introduce new
placeholder-specific helpers (e.g., ui_text_input_with_placeholder and
ui_textarea_with_placeholder or rename the current *_with_value variants) that
accept a placeholder parameter so callers can opt into placeholder behavior;
update or add the corresponding helper names (ui_text_input_with_value,
ui_textarea_with_value, or new *_with_placeholder) so the intent is clear and
existing guest code continues to prefill fields as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/shadcn-demo/src/lib.rs`:
- Around line 112-136: The render loop currently creates new Strings each frame
via to_string()/format! for the email preview, message stats, and prefs, which
causes per-frame heap allocations; fix this by reusing preallocated mutable
String buffers (e.g., preview_buf, stats_buf, prefs_buf) declared outside the
hot render loop, clear them each frame and write into them with write! or
push_str/format_to-like operations, and pass their &str slices to ui_label and
ui_label_muted (or for the email preview use a borrowed &str when
email.is_empty() -> "(no email yet)" to avoid allocation); update the code paths
that build preview, the Message: ... string, and prefs (references to variables
email, message, remember, marketing, ui_label, ui_label_muted) to use these
reusable buffers instead of to_string()/format!.

In `@oxide-browser/src/ui.rs`:
- Around line 4800-4812: The stored widget edit offsets in tab.widget_edits
(variable edit) are being reused against the freshly loaded text (variable text)
and can fall on invalid UTF-8 byte boundaries, causing panics when slicing
(&text[range]) or mutating (replace_range, insert_str); before any slicing or
mutation, validate and normalize the edit offsets against the current text by
clamping start/end to 0..=text.len() and moving each offset to the nearest valid
char boundary (using str::is_char_boundary or scanning with char_indices) so
ranges become safe; apply this normalization wherever edit offsets are used (the
locations around the retrieval of text/edit and before any
replace_range/insert_str usages) and consider adding a small helper (e.g.,
normalize_widget_edit_offsets(edit, &text)) to centralize the logic.
- Around line 3989-4003: The widget_edits and widget_bounds_cache maps are never
pruned and can grow unbounded; before inserting current-frame entries in the
loop over widget_commands (the match handling WidgetCommand::TextInput and
WidgetCommand::Textarea), compute the set of current editable widget IDs from
widget_commands, then remove any keys from self.tabs[active].widget_edits and
self.tabs[active].widget_bounds_cache that are not in that set (and also clear
any stale text_input_focus if its id is no longer present); after pruning,
proceed to or_insert the Bounds and other per-frame entries as you already do.

---

Outside diff comments:
In `@oxide-browser/src/capabilities.rs`:
- Around line 3964-4011: api_ui_text_input currently writes up to out_cap bytes
but returns write_len, hiding whether the string was truncated; change the
handler to still write min(bytes.len(), out_cap) via write_guest_bytes (using
out_ptr/out_cap) but return the full required length (bytes.len() as u32) so the
guest can detect truncation and resize; apply the identical change to
api_ui_textarea where WidgetValue::Text is read/written, and ensure you keep
using read_guest_string, write_guest_bytes, out_ptr and out_cap as in the
current implementation while only changing the returned value.

In `@oxide-sdk/src/lib.rs`:
- Around line 3491-3521: The wrappers accidentally changed the semantics of
ui_text_input and ui_textarea to treat their string argument as a placeholder
instead of the original initial value; restore backward compatibility by making
ui_text_input and ui_textarea keep the original "initial value" behavior (i.e.,
forward their string argument as the initial value to the underlying
implementation or to ui_text_input_with_value/ui_textarea_with_value), and
introduce new placeholder-specific helpers (e.g., ui_text_input_with_placeholder
and ui_textarea_with_placeholder or rename the current *_with_value variants)
that accept a placeholder parameter so callers can opt into placeholder
behavior; update or add the corresponding helper names
(ui_text_input_with_value, ui_textarea_with_value, or new *_with_placeholder) so
the intent is clear and existing guest code continues to prefill fields as
before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 417024e6-3910-489b-a69e-2f52533b50fa

📥 Commits

Reviewing files that changed from the base of the PR and between 5b7ef9c and 0ae5321.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • Cargo.toml
  • examples/hello-oxide/src/lib.rs
  • examples/shadcn-demo/Cargo.toml
  • examples/shadcn-demo/src/lib.rs
  • oxide-browser/src/capabilities.rs
  • oxide-browser/src/ui.rs
  • oxide-sdk/src/lib.rs

Comment on lines +112 to +136
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Avoid per-frame heap allocations in the render loop.

Line 114/116/126/131 allocate new Strings every frame (to_string/format!). In this hot path, that violates the allocation-minimal guest guideline and adds avoidable wasm overhead.

Suggested change (remove obvious per-frame allocations)
-    let preview = if email.is_empty() {
-        "(no email yet)".to_string()
-    } else {
-        format!("📧  {email}")
-    };
-    ui_label(440.0, row_y, &preview, 14.0);
+    if email.is_empty() {
+        ui_label(440.0, row_y, "(no email yet)", 14.0);
+    } else {
+        ui_label(440.0, row_y, email.as_str(), 14.0);
+    }

As per coding guidelines, "{oxide-sdk,examples}/**/src/**/*.rs: Guest app code must remain allocation-minimal since examples run on wasm32-unknown-unknown with no std allocator by default unless alloc is explicitly linked".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
let mut row_y = 376.0;
if email.is_empty() {
ui_label(440.0, row_y, "(no email yet)", 14.0);
} else {
ui_label(440.0, row_y, email.as_str(), 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);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/shadcn-demo/src/lib.rs` around lines 112 - 136, The render loop
currently creates new Strings each frame via to_string()/format! for the email
preview, message stats, and prefs, which causes per-frame heap allocations; fix
this by reusing preallocated mutable String buffers (e.g., preview_buf,
stats_buf, prefs_buf) declared outside the hot render loop, clear them each
frame and write into them with write! or push_str/format_to-like operations, and
pass their &str slices to ui_label and ui_label_muted (or for the email preview
use a borrowed &str when email.is_empty() -> "(no email yet)" to avoid
allocation); update the code paths that build preview, the Message: ... string,
and prefs (references to variables email, message, remember, marketing,
ui_label, ui_label_muted) to use these reusable buffers instead of
to_string()/format!.

Comment thread oxide-browser/src/ui.rs
Comment on lines +3989 to 4003
// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prune stale editable-widget caches before inserting current-frame entries.

widget_edits and widget_bounds_cache only grow. If guests emit transient/incrementing widget IDs, these maps grow unbounded and can leave stale text_input_focus IDs around.

💡 Suggested fix
-            // 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 editable_ids: HashSet<u32> = widget_commands
+                .iter()
+                .filter_map(|cmd| match cmd {
+                    WidgetCommand::TextInput { id, .. } | WidgetCommand::Textarea { id, .. } => {
+                        Some(*id)
+                    }
+                    _ => None,
+                })
+                .collect();
+
+            {
+                let tab = &mut self.tabs[active];
+                tab.widget_edits.retain(|id, _| editable_ids.contains(id));
+                tab.widget_bounds_cache
+                    .retain(|id, _| editable_ids.contains(id));
+                if let Some(focused_id) = tab.text_input_focus {
+                    if !editable_ids.contains(&focused_id) {
+                        tab.text_input_focus = None;
+                    }
+                }
+                for id in &editable_ids {
+                    tab.widget_bounds_cache
+                        .entry(*id)
+                        .or_insert_with(|| Arc::new(Mutex::new(Bounds::default())));
+                }
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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 editable_ids: HashSet<u32> = widget_commands
.iter()
.filter_map(|cmd| match cmd {
WidgetCommand::TextInput { id, .. } | WidgetCommand::Textarea { id, .. } => {
Some(*id)
}
_ => None,
})
.collect();
{
let tab = &mut self.tabs[active];
tab.widget_edits.retain(|id, _| editable_ids.contains(id));
tab.widget_bounds_cache
.retain(|id, _| editable_ids.contains(id));
if let Some(focused_id) = tab.text_input_focus {
if !editable_ids.contains(&focused_id) {
tab.text_input_focus = None;
}
}
for id in &editable_ids {
tab.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();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@oxide-browser/src/ui.rs` around lines 3989 - 4003, The widget_edits and
widget_bounds_cache maps are never pruned and can grow unbounded; before
inserting current-frame entries in the loop over widget_commands (the match
handling WidgetCommand::TextInput and WidgetCommand::Textarea), compute the set
of current editable widget IDs from widget_commands, then remove any keys from
self.tabs[active].widget_edits and self.tabs[active].widget_bounds_cache that
are not in that set (and also clear any stale text_input_focus if its id is no
longer present); after pruning, proceed to or_insert the Bounds and other
per-frame entries as you already do.

Comment thread oxide-browser/src/ui.rs
Comment on lines +4800 to +4812
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard widget edit offsets against stale UTF-8 boundaries.

Line 4811 reuses persisted cursor/selection offsets without normalizing to the current text. If guest code changes widget text between frames, subsequent &text[range], replace_range, or insert_str can panic on invalid boundaries/ranges.

💡 Suggested fix
+fn clamp_utf8_boundary(text: &str, mut idx: usize) -> usize {
+    idx = idx.min(text.len());
+    while idx > 0 && !text.is_char_boundary(idx) {
+        idx -= 1;
+    }
+    idx
+}
+
 fn handle_widget_key(view: &mut OxideBrowserView, id: u32, event: &KeyDownEvent) {
@@
-    let mut edit = tab.widget_edits.get(&id).cloned().unwrap_or_default();
+    let mut edit = tab.widget_edits.get(&id).cloned().unwrap_or_default();
+    edit.cursor = clamp_utf8_boundary(&text, edit.cursor);
+    edit.sel_start = clamp_utf8_boundary(&text, edit.sel_start);

Also applies to: 4825-4833, 4850-4858, 4974-5001

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@oxide-browser/src/ui.rs` around lines 4800 - 4812, The stored widget edit
offsets in tab.widget_edits (variable edit) are being reused against the freshly
loaded text (variable text) and can fall on invalid UTF-8 byte boundaries,
causing panics when slicing (&text[range]) or mutating (replace_range,
insert_str); before any slicing or mutation, validate and normalize the edit
offsets against the current text by clamping start/end to 0..=text.len() and
moving each offset to the nearest valid char boundary (using
str::is_char_boundary or scanning with char_indices) so ranges become safe;
apply this normalization wherever edit offsets are used (the locations around
the retrieval of text/edit and before any replace_range/insert_str usages) and
consider adding a small helper (e.g., normalize_widget_edit_offsets(edit,
&text)) to centralize the logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant