teksilo-widgets 0.9.2

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Default `TextInputStyle` impl driven by paint-recipe data.
//!
//! `RecipeTextInputStyle` ships the IntUI chrome: a bordered rectangle
//! around the editor area with a horizontal-padding inset, the border
//! thickening and recolouring on focus, and validation tints (error /
//! warning / corrected) overriding focus when set. The validation
//! strip below the field is the widget's responsibility — the trait
//! recipe is just the bordered frame.
//!
//! The recipe describes border / fill / corner radius only; the rest
//! stays on the widget. Caret blinking, IME composition, placeholder
//! layering, leading / trailing slots, clear button, the
//! ValidationStrip below — all stay on the public `TextInput` widget.
//!
//! Variants:
//!
//! - `Outlined` (default) — 1 dp border in the theme's default border
//!   role; thickens to `focus_ring_width` on focus.
//! - `Filled` — accent-subtle background, no border. Material 3 style.
//! - `Underline` — transparent surface with a single bottom border.
//!   For now this is rendered as Outlined with the same border on all
//!   sides; a true bottom-only stroke arrives once `BorderPosition` /
//!   per-side stroke recipes land.
//! - `Bare` — no chrome at all. Returns the editor verbatim. Used by
//!   parents that own the chrome themselves (search fields, combo box
//!   filter input).

use teksilo_core::build_context::BuildContext;
use teksilo_core::color_prop::ColorProp;
use teksilo_core::signal::Signal;
use teksilo_core::styles::{
    TextInputStyle, TextInputStyleConfig, TextInputValidationLevel, TextInputVariant,
};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};

use crate::primitives::{MinSize, Padding, RectWidget, ZStack};

// IntUI design tokens for TextInput / TextInputField (also used by
// SpinBox, DateEdit, DateRangeEdit, DateTimeEdit since they share the
// same form-field baseline). The recipe and form-field composers own
// these constants.
pub const TEXT_FIELD_HEIGHT: f32 = 28.0;
pub const TEXT_FIELD_PADDING_HORIZONTAL: f32 = 4.0;
pub const TEXT_FIELD_PADDING_VERTICAL: f32 = 4.0;
pub const TEXT_FIELD_BORDER_WIDTH: f32 = 1.0;
pub const TEXT_FIELD_CORNER_RADIUS: f32 = 4.0;
pub const TEXT_FIELD_CARET_WIDTH: f32 = 1.0;
pub const TEXT_FIELD_VALIDATION_STRIP_GAP: f32 = 4.0;
pub const TEXT_FIELD_ERROR_PULSE_DURATION_MS: u32 = 240;
pub const TEXT_FIELD_CORRECTED_PULSE_DURATION_MS: u32 = 1500;
pub const TEXT_FIELD_MASK_PLACEHOLDER_CHAR: char = '_';

/// Dimension recipe for [`RecipeTextInputStyle`].
///
/// Every `pub const TEXT_FIELD_*` is mirrored as a typed field so callers
/// can override individual dimensions without writing a full custom style.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextInputRecipe {
    pub height: f32,
    pub padding_horizontal: f32,
    pub padding_vertical: f32,
    pub border_width: f32,
    pub corner_radius: f32,
    pub caret_width: f32,
    pub validation_strip_gap: f32,
    pub error_pulse_duration_ms: u32,
    pub corrected_pulse_duration_ms: u32,
    pub mask_placeholder_char: char,
}

impl Default for TextInputRecipe {
    fn default() -> Self {
        Self {
            height: TEXT_FIELD_HEIGHT,
            padding_horizontal: TEXT_FIELD_PADDING_HORIZONTAL,
            padding_vertical: TEXT_FIELD_PADDING_VERTICAL,
            border_width: TEXT_FIELD_BORDER_WIDTH,
            corner_radius: TEXT_FIELD_CORNER_RADIUS,
            caret_width: TEXT_FIELD_CARET_WIDTH,
            validation_strip_gap: TEXT_FIELD_VALIDATION_STRIP_GAP,
            error_pulse_duration_ms: TEXT_FIELD_ERROR_PULSE_DURATION_MS,
            corrected_pulse_duration_ms: TEXT_FIELD_CORRECTED_PULSE_DURATION_MS,
            mask_placeholder_char: TEXT_FIELD_MASK_PLACEHOLDER_CHAR,
        }
    }
}

/// Default `TextInputStyle` shipped with Teksilo.
#[derive(Debug, Default, Clone, Copy)]
pub struct RecipeTextInputStyle {
    pub recipe: TextInputRecipe,
}

impl RecipeTextInputStyle {
    pub fn new(recipe: TextInputRecipe) -> Self {
        Self { recipe }
    }
}

impl TextInputStyle for RecipeTextInputStyle {
    fn make_body(&self, cfg: &TextInputStyleConfig, ctx: &mut BuildContext) -> WidgetId {
        let theme = ctx.theme();
        let border_width = self.recipe.border_width;
        let focus_ring_width = theme.shape.focus_ring_width;
        let padding_h = self.recipe.padding_horizontal;
        let corner_radius = self.recipe.corner_radius;
        let height = self.recipe.height;

        // Bare variant: no chrome at all. Just hand the editor back
        // wrapped in a MinSize so consumers still get a predictable
        // intrinsic height.
        if matches!(cfg.variant, TextInputVariant::Bare) {
            return ctx.add(MinSize::new(0.0, height).child_id(cfg.editor));
        }

        // Derived border role: disabled trumps everything (an inert field
        // must not shout a validation error the user cannot act on), then
        // validation outcome trumps focus, and focus trumps default.
        let border_role = derive_border_role(
            cfg.is_focused.clone(),
            cfg.validation.clone(),
            cfg.is_disabled.clone(),
        );

        // Border width: thickens to focus_ring_width when focused,
        // regardless of validation. For `Filled`, force 0.
        let variant = cfg.variant;
        let border_width_signal = cfg.is_focused.map(move |focused| match variant {
            TextInputVariant::Filled => 0.0,
            _ => {
                if *focused {
                    focus_ring_width
                } else {
                    border_width
                }
            }
        });

        // Background role. `SurfaceRole::Field` is `Content`'s twin for
        // *interactive* surfaces: identical while enabled, but it dims to
        // `SurfaceRole::Disabled` inside `ColorProp::resolve` at paint time.
        // Going through that hook (rather than switching the role from
        // `cfg.is_disabled` here) is what makes a field dim when an
        // *ancestor* is disabled — `is_disabled` is derived from
        // `effective_enabled_signal`, which cannot see ancestors, since a
        // widget's parent is not wired yet during its own `build()`.
        // Filled keeps its faint tint, and dims from the signal.
        let bg_role: ColorProp = match variant {
            TextInputVariant::Filled => ColorProp::DynamicSurfaceRole(cfg.is_disabled.map(|d| {
                if *d {
                    SurfaceRole::Disabled
                } else {
                    SurfaceRole::Hover
                }
            })),
            _ => SurfaceRole::Field.into(),
        };

        let bg = RectWidget::new()
            .background(bg_role)
            .border_color(border_role)
            .border_width(border_width_signal)
            .corner_radius(CornerRadius::uniform(corner_radius));
        let bg_id = ctx.add(bg);

        // Horizontal-only padding so leading / trailing slots inside
        // the editor row sit flush against top and bottom of the frame.
        let padded_id = ctx.add(Padding::new(0.0, padding_h, 0.0, padding_h).child_id(cfg.editor));

        let zstack_id = ctx.add(ZStack::new().add_child(bg_id).add_child(padded_id));
        ctx.add(MinSize::new(0.0, height).child_id(zstack_id))
    }
}

/// Derive the border role from disabled + focus + validation. Disabled
/// outranks both — an inert field reads as grey, not as a live error the
/// user could still fix. Below that, validation tints override the focus
/// tint, so a typo in a focused field still reads as an error rather than
/// as "focused and fine".
fn derive_border_role(
    is_focused: Signal<bool>,
    validation: Signal<TextInputValidationLevel>,
    is_disabled: Signal<bool>,
) -> Signal<BorderRole> {
    is_focused
        .zip3(&validation, &is_disabled)
        .map(|(focused, level, disabled)| {
            if *disabled {
                return BorderRole::Disabled;
            }
            match *level {
                TextInputValidationLevel::Error => BorderRole::Error,
                TextInputValidationLevel::Warning => BorderRole::Warning,
                // Corrected: accent tint (matches the IntUI "we changed
                // something — look here briefly" cue). The decay back to
                // default is driven by the widget setting the validation
                // signal back to None after the corrected pulse.
                TextInputValidationLevel::Corrected | TextInputValidationLevel::Info => {
                    BorderRole::Focused
                }
                TextInputValidationLevel::None => {
                    if *focused {
                        BorderRole::Focused
                    } else {
                        // `Field`, not `Default`: same colour while enabled,
                        // but it dims at paint time even when the field is
                        // only disabled via an ancestor. See `bg_role`.
                        BorderRole::Field
                    }
                }
            }
        })
}