Buttons & Inputs

Blinc provides ready-to-use input widgets. Their hover, press and checked looks are patched in place from signals; they don't rebuild when the pointer moves.

Buttons

Basic Button

#![allow(unused)]
fn main() {
use blinc::layout::prelude::*;

button("Save").on_click(|_| println!("Saved!"))
}

A button keeps its pointer state under a key made from where it was created. Buttons made in a loop from one line need a key each:

#![allow(unused)]
fn main() {
div().flex_row().gap(2.0).children(
    ["Cut", "Copy", "Paste"].map(|label| button(label).key(format!("edit-{label}"))),
)
}

Styled Buttons

A button is an element: :hover, :active and :disabled match it, so how it looks in each state, and how it eases between them, is a stylesheet. button() starts from the theme's primary look (the .blinc-button rules of blinc::layout::ua_styles::UA_STYLES); give it a class and write your own rules, which win over the default look:

#![allow(unused)]
fn main() {
button("Delete").class("danger").rounded(8.0).px(4.0)
}
.danger { background: #c0392b; transition: background-color 150ms ease; }
.danger:hover:not(:disabled) { background: #e74c3c; }
.danger:active:not(:disabled) { background: #a93226; }
.danger:disabled { background: #555; color: #999; }

.id(..) works the same way. A fluent value such as .bg_color(..) fixes that property in every state, as an inline style does on the web.

A Label That Follows a Signal

The label takes a string or a signal, and .disabled(..) takes a bool or a signal. Both are patched in place:

#![allow(unused)]
fn main() {
use blinc::core::reactive::{computed, signal};

let saving = signal(false);
let label = computed(move |g| {
    if g.get(saving).unwrap_or(false) { "Saving…".to_string() } else { "Save".to_string() }
});

button(label)
    .disabled(saving)
    .on_click(move |_| saving.set(true))
}

Custom Content Buttons

button_with builds the content once. Text and icons inside take the button's color, so a rule that changes it on :hover changes them too:

#![allow(unused)]
fn main() {
use blinc::layout::widgets::button_with;

button_with(|_| {
    div()
        .flex_row()
        .gap(2.0)
        .items_center()
        .child(svg(SAVE_ICON).size(16.0, 16.0))
        .child(text("Save"))
})
.on_click(|_| save_file())
}

The closure is also given a ButtonLook; look.interaction() has the button's hovered, pressed and focused signals for content that needs them.

Disabled Buttons

#![allow(unused)]
fn main() {
button("Cannot Click").disabled(true)
}

A disabled button matches :disabled and does not click. The default look gives it the theme's disabled fill and muted text; restyle it with a :disabled rule.


Checkboxes

Basic Checkbox

#![allow(unused)]
fn main() {
use blinc::layout::widgets::checkbox::checkbox;

let remember = ctx.use_signal(false);

checkbox(remember).on_change(|checked| println!("Checkbox is now: {checked}"))
}

The checkbox reads and writes the Signal<bool> it is given, so the signal is the source of truth: set it from elsewhere and the box follows. .disabled(..) and .label(..) take a value, Signal or Computed and follow it in place.

Labeled Checkbox

#![allow(unused)]
fn main() {
checkbox(remember).label("Remember me")
}

checkbox_labeled(remember, "Remember me") is the same.

Styled Checkbox

A checkbox is an element with a checked attribute while it is checked and disabled while it cannot be used. Its fill, border colour, hover, the disabled palette and the mark's fade-in are the .blinc-checkbox rules of blinc::layout::ua_styles::UA_STYLES, with a transition. Restyle it with rules of your own; they win over the defaults:

.my-checkbox:checked { background: #16a34a; border-color: #16a34a; }
.my-checkbox:hover:not(:disabled):not(:checked) { border-color: #16a34a; }
#![allow(unused)]
fn main() {
checkbox(remember)
    .class("my-checkbox")   // on the box
    .checkbox_size(20.0)    // sizes are layout, so they stay in Rust
    .border_width(2.0)
    .rounded(4.0)
}

.mark_size(..), .gap(..) and .label_font_size(..) size the parts; colours come from rules, so there are no colour setters.

Initially Checked

#![allow(unused)]
fn main() {
let remember = ctx.use_signal(true); // Start checked
}

Radio Groups

#![allow(unused)]
fn main() {
use blinc::layout::widgets::radio_group;

let theme = ctx.use_signal("light".to_string());

radio_group(theme)
    .label("Theme")
    .horizontal()
    .option("light", "Light")
    .option("dark", "Dark")
    .option_disabled("auto", "Auto")
    .on_change(|value| println!("Selected: {value}"))
}

The group reads and writes a State<String> holding the selected value.

Styled Radio Group

Each ring has a checked attribute while its option is selected and disabled while it cannot be chosen. The ring's border, the dot's fade-in, hover (which also highlights an unselected option) and the disabled fill are the .blinc-radio and .blinc-radio-dot rules of blinc::layout::ua_styles::UA_STYLES, with a transition. Restyle them with rules of your own:

.blinc-radio:checked { border-color: #16a34a; }
.blinc-radio:checked > .blinc-radio-dot { background: #16a34a; }

Sizes are layout, so they stay in Rust (.gap(..) and the group's label); colours come from rules, so there are no colour setters.


Text Input

Basic Text Input

#![allow(unused)]
fn main() {
use blinc::layout::widgets::text_input::{text_input, text_input_state_with_placeholder};

fn my_ui(ctx: &WindowedContext) -> impl ElementBuilder {
    let state = text_input_state_with_placeholder("Enter your name...");

    text_input(&state)
        .w(300.0)
        .on_change(|text| {
            println!("Input: {}", text);
        })
}
}

Styled Text Input

#![allow(unused)]
fn main() {
text_input(&state)
    .w(300.0)
    .rounded(8.0)
    .text_color(Color::WHITE)
    .placeholder_color(Color::rgba(0.5, 0.5, 0.6, 1.0))
    .class("my-field")
}
.my-field { background: #262633; }
.my-field:focus { border-color: #6699ff; }

A text input is one element with the class blinc-input. It has disabled while it cannot be edited and data-invalid while its value fails validation, so its look in each state, and how it eases between them, is the .blinc-input rules of the default stylesheet. idle_bg_color and idle_border_color set the idle values as inline style, which the default rules do not replace.

Reading Input Value

#![allow(unused)]
fn main() {
let state = text_input_state();

// Later, read the current value
let current_text = state.lock().unwrap().value.clone();
}

Setting the Value from Code

The field is built once. Typing, focus, hover and scrolling patch what it shows in place. After changing its data from outside the widget, as a stepper button does, call refresh_text_input so the field shows the change:

#![allow(unused)]
fn main() {
use blinc::layout::widgets::text_input::refresh_text_input;

{
    let mut data = state.lock().unwrap();
    data.value = "42".to_string();
    data.cursor = data.value.chars().count();
}
refresh_text_input(&state);
}

Mobile input methods

The iOS and Android hosts connect the focused editable widget to the system keyboard. Input methods can keep provisional text, move the selection, and replace that text with a candidate. Provisional text is visible while composing; on_change fires when it commits. A composition is one undo step in single-line, code and rich text editors. Text areas also retain multiline candidate edits.

Single-line constraints are checked on the complete commit. A rejected candidate restores the previous value and selection. Moving focus commits the current composition; disabling a field cancels its provisional edit. Replacing the document from application code cancels its old native editing session, so a delayed candidate cannot overwrite the new value.

The input and text area's provisional text uses .blinc-ime-composition, whose default style is text-decoration: underline. Include fonts that cover the scripts your application accepts. Android and iOS load their system colour-emoji fonts when needed.

New CLI-generated mobile projects include the keyboard connection. When updating an existing iOS host, update BlincAccessibility.swift and its bridging header together and keep calling synchronize() after building a frame. On Android, include both BlincNativeBridge.kt and BlincIme.kt, register the bridge with the Activity, and initialize the Rust native bridge before running the app. The GPU continues to draw the controls; the native editor owns the keyboard's marked text and candidate geometry.

OTP Input

input_otp renders linked single-character text input slots for verification codes and PINs. It keeps a joined State<String> in sync while handling focus movement across slots.

#![allow(unused)]
fn main() {
use blinc::layout::widgets::input_otp::input_otp;

fn verification_code(ctx: &WindowedContext) -> impl ElementBuilder {
    let code = ctx.use_signal(String::new());

    input_otp(code, 6)
        .numeric_only(true)
        .on_complete(|code| println!("complete code: {code}"))
}
}

Behavior

  • Typing a character advances focus to the next slot.
  • Backspace on an empty slot rewinds focus and clears the previous slot.
  • Pasting distributes characters across the remaining slots.
  • numeric_only(true) filters typed and pasted input to ASCII digits.

Options

#![allow(unused)]
fn main() {
input_otp(&code, 6)
    .numeric_only(true)
    .masked(false)
    .gap(8.0)
    .class("verification-code")
}

Text Area

Basic Text Area

#![allow(unused)]
fn main() {
use blinc::layout::widgets::text_area::{text_area, text_area_state_with_placeholder};

fn my_ui(ctx: &WindowedContext) -> impl ElementBuilder {
    let state = text_area_state_with_placeholder("Enter description...");

    text_area(&state)
        .w(400.0)
        .rows(6)
        .on_change(|text| {
            println!("Content: {}", text);
        })
}
}

Styled Text Area

Size and shape are set on the builder. Colours come from the stylesheet, by class or id, per state:

#![allow(unused)]
fn main() {
text_area(&state)
    .w(400.0)
    .rows(6)
    .rounded(8.0)
    .font_size(14.0)
    .class("notes")
}

Placeholder and selection colours are the standard pseudo-elements, .notes::placeholder { color: .. } and .notes::selection { background: .. }, on a class or an id; caret-color sets the caret.

.notes::placeholder { color: #8888aa; }
.notes::selection { background: #3355aa; }
.notes { background: #1e1e2a; color: #ffffff; caret-color: #6699ff; }
.notes:hover { background: #24243a; }
.notes:focus { border-color: #6699ff; }

Setting the Text from Code

Like the text input, the text area is built once and patches what it shows in place. After changing its state from outside the widget, call refresh_text_area so the field shows the change:

#![allow(unused)]
fn main() {
use blinc::layout::widgets::text_area::refresh_text_area;

state.lock().unwrap().set_value("First line\nSecond line");
refresh_text_area(&state);
}

Code Editor

Syntax Highlighted Code

use blinc::layout::widgets::code::code;

fn my_ui() -> impl ElementBuilder {
    let source = r#"
fn main() {
    println!("Hello, Blinc!");
}
"#;

    code(source)
        .lang("rust")
        .w_full()
        .h(300.0)
        .rounded(8.0)
        .font("Fira Code")
        .size(14.0)
}

Supported Languages

  • rust, python, javascript, typescript
  • html, css, json, yaml, xml
  • sql, bash, go, java, c, cpp
  • And more...

Form Example

#![allow(unused)]
fn main() {
fn login_form(ctx: &WindowedContext) -> impl ElementBuilder {
    let email_state = text_input_state_with_placeholder("Email address");
    let password_state = text_input_state_with_placeholder("Password");
    let remember_state = ctx.use_signal(false);

    div()
        .w(400.0)
        .p(24.0)
        .rounded(16.0)
        .bg(Color::rgba(0.12, 0.12, 0.16, 1.0))
        .flex_col()
        .gap(16.0)
        // Title
        .child(
            text("Sign In")
                .size(24.0)
                .weight(FontWeight::Bold)
                .color(Color::WHITE)
        )
        // Email field
        .child(
            div()
                .flex_col()
                .gap(4.0)
                .child(label("Email").color(Color::WHITE))
                .child(
                    text_input(&email_state)
                        .w_full()
                        .rounded(8.0)
                )
        )
        // Password field
        .child(
            div()
                .flex_col()
                .gap(4.0)
                .child(label("Password").color(Color::WHITE))
                .child(
                    text_input(&password_state)
                        .w_full()
                        .rounded(8.0)
                        // Note: password masking would be a feature to add
                )
        )
        // Remember me
        .child(
            checkbox(remember_state).label("Remember me")
        )
        // Submit button
        .child(
            button("Sign In")
                .w_full()
                .bg_color(Color::rgba(0.3, 0.5, 0.9, 1.0))
                .text_color(Color::WHITE)
                .rounded(8.0)
                .on_click(|_| {
                    println!("Form submitted!");
                })
        )
}
}

Widget State

WidgetStateSource
ButtonHovered, pressed, disabled:hover, :active and :disabled rules; .disabled(..)
CheckboxChecked, hovered, disabledchecked and disabled attributes from the State<bool> it is given; :hover rules
TextInputHovered, focused, invalid, disabled:hover, :focus, data-invalid and :disabled rules; its own data supplies the attributes
TextAreaHovered, focused, disabledSame rules as TextInput (.blinc-input)

Best Practices

  1. Use unique keys for state - Each widget needs its own state key.

  2. Handle validation in on_change - Validate input as users type.

  3. Provide visual feedback - Use colors to indicate focus and errors.

  4. Group related inputs - Use flex containers to organize forms.

  5. Add labels - Every input should have an associated label for accessibility.