Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Form

Group: Content

Code

/// Variant 1 ("Search & replace") state: a mini find/replace panel.
struct SearchReplaceState {
    search_query: String,
    replace_query: String,
    case_sensitive: bool,
    whole_word: bool,
    regex: bool,
    focus: FocusRing,
    last_action: String,
}

impl SearchReplaceState {
    fn new() -> Self {
        Self {
            search_query: "hello".into(),
            replace_query: String::new(),
            case_sensitive: true,
            whole_word: false,
            regex: false,
            focus: FocusRing::new(vec!["search", "toggles", "replace", "buttons"]),
            last_action: "—".into(),
        }
    }

    fn build_form(&self) -> Form {
        Form {
            id: WidgetId::new("gallery:form:search-replace"),
            fields: vec![
                FormField {
                    id: WidgetId::new("search"),
                    label: StyledText::plain("Find"),
                    kind: FieldKind::TextInput {
                        value: self.search_query.clone(),
                        placeholder: "Search…".into(),
                        cursor: Some(self.search_query.len()),
                        selection_anchor: None,
                    },
                    hint: StyledText::default(),
                    disabled: false,
                    validation: None,
                },
                FormField {
                    id: WidgetId::new("toggles"),
                    label: StyledText::default(),
                    kind: FieldKind::ToggleGroup {
                        toggles: vec![
                            ToggleGroupItem {
                                id: WidgetId::new("case"),
                                label: "Aa".into(),
                                value: self.case_sensitive,
                            },
                            ToggleGroupItem {
                                id: WidgetId::new("word"),
                                label: "Ab|".into(),
                                value: self.whole_word,
                            },
                            ToggleGroupItem {
                                id: WidgetId::new("regex"),
                                label: ".*".into(),
                                value: self.regex,
                            },
                        ],
                    },
                    hint: StyledText::default(),
                    disabled: false,
                    validation: None,
                },
                FormField {
                    id: WidgetId::new("replace"),
                    label: StyledText::plain("Replace"),
                    kind: FieldKind::TextInput {
                        value: self.replace_query.clone(),
                        placeholder: "Replace…".into(),
                        cursor: Some(self.replace_query.len()),
                        selection_anchor: None,
                    },
                    hint: StyledText::default(),
                    disabled: false,
                    validation: None,
                },
                FormField {
                    id: WidgetId::new("buttons"),
                    label: StyledText::default(),
                    kind: FieldKind::ButtonRow {
                        buttons: vec![
                            ButtonRowItem {
                                id: WidgetId::new("find-next"),
                                label: "Find Next".into(),
                                disabled: false,
                                icon: None,
                            },
                            ButtonRowItem {
                                id: WidgetId::new("replace-one"),
                                label: "Replace".into(),
                                disabled: false,
                                icon: None,
                            },
                            ButtonRowItem {
                                id: WidgetId::new("replace-all"),
                                label: "Replace All".into(),
                                disabled: self.search_query.is_empty(),
                                icon: None,
                            },
                        ],
                    },
                    hint: StyledText::default(),
                    disabled: false,
                    validation: None,
                },
            ],
            focused_field: self.focus.current().cloned(),
            scroll_offset: 0,
            has_focus: true,
        }
    }

    fn click(&mut self, id: &WidgetId) {
        match id.as_str() {
            "case" => {
                self.case_sensitive = !self.case_sensitive;
                self.last_action = format!("case={}", self.case_sensitive);
            }
            "word" => {
                self.whole_word = !self.whole_word;
                self.last_action = format!("word={}", self.whole_word);
            }
            "regex" => {
                self.regex = !self.regex;
                self.last_action = format!("regex={}", self.regex);
            }
            "find-next" => self.last_action = "Find Next".into(),
            "replace-one" => self.last_action = "Replace".into(),
            "replace-all" => {
                if !self.search_query.is_empty() {
                    self.last_action = "Replace All".into();
                }
            }
            other => {
                self.focus.set(id);
                self.last_action = format!("focus → {other}");
            }
        }
    }
}

/// Variant 2 ("Scrolling") state: 20 toggles, enough to overflow a
/// typical gallery viewport and exercise `FormController`'s scrollbar.
struct ScrollState {
    fc: FormController,
    toggles: Vec<bool>,
    last_action: String,
}

fn scroll_form_from_toggles(toggles: &[bool]) -> Form {
    let fields: Vec<FormField> = toggles
        .iter()
        .enumerate()
        .map(|(i, &val)| FormField {
            id: WidgetId::new(format!("toggle-{i}")),
            label: StyledText::plain(format!("Setting {}", i + 1)),
            kind: FieldKind::Toggle { value: val },
            hint: StyledText::default(),
            disabled: false,
            validation: None,
        })
        .collect();
    Form {
        id: WidgetId::new("gallery:form:settings-fields"),
        fields,
        focused_field: None,
        scroll_offset: 0,
        has_focus: true,
    }
}

impl ScrollState {
    fn new() -> Self {
        let toggles = vec![false; 20];
        let mut fc = FormController::new("gallery:form:settings".to_string());
        // Seed the form immediately so the very first render (before any
        // `handle()` event reaches `FormController`) isn't blank —
        // `FormController::render` reads back whatever `set_form` last
        // stored, it never builds from scratch.
        fc.set_form(scroll_form_from_toggles(&toggles));
        Self {
            fc,
            toggles,
            last_action: "—".into(),
        }
    }

    fn build_form(&self) -> Form {
        scroll_form_from_toggles(&self.toggles)
    }
}

/// All `FieldKind` variants, laid out statically — variant 0 ("All field
/// kinds"). No interactive state: this is a reference sheet, not a live
/// control surface.
fn build_all_fields_form() -> Form {
    Form {
        id: WidgetId::new("gallery:form:all-fields"),
        fields: vec![
            FormField {
                id: WidgetId::new("hdr"),
                label: StyledText::plain("Editor"),
                kind: FieldKind::Label,
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("line-numbers"),
                label: StyledText::plain("Show line numbers"),
                kind: FieldKind::Toggle { value: true },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("name"),
                label: StyledText::plain("Name"),
                kind: FieldKind::TextInput {
                    value: "quadraui".into(),
                    placeholder: String::new(),
                    cursor: Some(4),
                    selection_anchor: None,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("save"),
                label: StyledText::plain("Save settings"),
                kind: FieldKind::Button,
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("version"),
                label: StyledText::plain("Version"),
                kind: FieldKind::ReadOnly {
                    value: StyledText::plain("v0.1.2"),
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("font-size"),
                label: StyledText::plain("Font size"),
                kind: FieldKind::Slider {
                    value: 14.0,
                    min: 8.0,
                    max: 32.0,
                    step: 1.0,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("accent"),
                label: StyledText::plain("Accent colour"),
                kind: FieldKind::ColorPicker {
                    value: Color::rgb(0x7a, 0xb4, 0xff),
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("theme"),
                label: StyledText::plain("Theme"),
                kind: FieldKind::Dropdown {
                    options: vec![
                        StyledText::plain("One Dark"),
                        StyledText::plain("Solarized Light"),
                    ],
                    selected_idx: 1,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("notes"),
                label: StyledText::plain("Notes"),
                kind: FieldKind::TextArea {
                    value: "Release notes go here.".into(),
                    placeholder: String::new(),
                    cursor: None,
                    visible_rows: 3,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("password"),
                label: StyledText::plain("Password"),
                kind: FieldKind::PasswordInput {
                    value: "secret1".into(),
                    placeholder: String::new(),
                    cursor: None,
                    mask_char: '•',
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("scope"),
                label: StyledText::default(),
                kind: FieldKind::SegmentedControl {
                    options: vec!["File".into(), "Project".into()],
                    selected_idx: 0,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("flags"),
                label: StyledText::plain("Flags"),
                kind: FieldKind::ToggleGroup {
                    toggles: vec![ToggleGroupItem {
                        id: WidgetId::new("case"),
                        label: "Case sensitive".into(),
                        value: true,
                    }],
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("actions"),
                label: StyledText::plain("Actions"),
                kind: FieldKind::ButtonRow {
                    buttons: vec![ButtonRowItem {
                        id: WidgetId::new("run"),
                        label: "Run action".into(),
                        disabled: false,
                        icon: None,
                    }],
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("toolbar"),
                label: StyledText::plain("Toolbar"),
                kind: FieldKind::Toolbar(
                    Toolbar::new(WidgetId::new("toolbar-field")).with_buttons(vec![
                        ToolbarButton::Action {
                            id: WidgetId::new("build"),
                            label: "Build".into(),
                            icon: None,
                            key_hint: None,
                            enabled: true,
                            is_active: false,
                            tooltip: "Build the project".into(),
                        },
                    ]),
                ),
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("due-date"),
                label: StyledText::plain("Due date"),
                kind: FieldKind::DatePicker(DatePicker::new(
                    WidgetId::new("due-date-picker"),
                    CalendarDate::new(2024, 3, 15),
                )),
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
        ],
        focused_field: None,
        scroll_offset: 0,
        has_focus: true,
    }
}

pub struct FormDemo {
    search_replace: SearchReplaceState,
    scroll: ScrollState,
}

impl FormDemo {
    pub fn new() -> Self {
        Self {
            search_replace: SearchReplaceState::new(),
            scroll: ScrollState::new(),
        }
    }

    fn status_bar(text: &str, hint: &str) -> StatusBar {
        let fg = Color::rgb(220, 220, 220);
        let bg = Color::rgb(40, 40, 60);
        StatusBar {
            id: WidgetId::new("gallery:form:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {text} "),
                fg,
                bg,
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: format!(" {hint} "),
                fg,
                bg,
                bold: false,
                action_id: None,
            }],
        }
    }

    fn form_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

impl Default for FormDemo {
    fn default() -> Self {
        Self::new()
    }
}

impl Demo for FormDemo {
    fn name(&self) -> &'static str {
        "Form"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["All field kinds", "Search & replace", "Scrolling"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let form_rect = Self::form_rect(area, backend);
        match variant {
            0 => backend.draw_form(area, &build_all_fields_form()),
            1 => {
                backend.draw_form(form_rect, &self.search_replace.build_form());
                let bar = Self::status_bar(
                    &format!("last: {}", self.search_replace.last_action),
                    "click / Tab / Shift+Tab",
                );
                let _ = backend.draw_status_bar_interactive(
                    Self::status_rect(area, backend),
                    &bar,
                    &InteractionState::new(),
                );
            }
            _ => {
                self.scroll.fc.render(backend, form_rect);
                let bar = Self::status_bar(
                    &format!(
                        "scroll={} | {}",
                        self.scroll.fc.scroll_offset(),
                        self.scroll.last_action
                    ),
                    "scroll / click / drag scrollbar",
                );
                let _ = backend.draw_status_bar_interactive(
                    Self::status_rect(area, backend),
                    &bar,
                    &InteractionState::new(),
                );
            }
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match variant {
            0 => Reaction::Continue,
            1 => match event {
                UiEvent::MouseDown {
                    button: MouseButton::Left,
                    position,
                    ..
                } => {
                    let form_rect = Self::form_rect(area, backend);
                    let layout = backend.form_layout(form_rect, &self.search_replace.build_form());
                    // `FormLayout`'s hit regions are relative to
                    // `form_rect`'s own top-left corner, not the
                    // surface origin (`compose::FormController::click_inner`
                    // does the same subtraction) — `position` is an
                    // absolute surface coordinate, which only happens to
                    // equal the local one when `form_rect` starts at
                    // `(0, 0)`, which it never does once this demo is
                    // embedded below the gallery's own chrome.
                    match layout.hit_test(position.x - form_rect.x, position.y - form_rect.y) {
                        FormHit::Field(id) => {
                            self.search_replace.click(&id);
                            Reaction::Redraw
                        }
                        FormHit::Empty => Reaction::Continue,
                    }
                }
                UiEvent::KeyPressed { key, .. } => match key {
                    Key::Named(NamedKey::Tab) => {
                        self.search_replace.focus.advance();
                        Reaction::Redraw
                    }
                    Key::Named(NamedKey::BackTab) => {
                        self.search_replace.focus.retreat();
                        Reaction::Redraw
                    }
                    _ => Reaction::Continue,
                },
                _ => Reaction::Continue,
            },
            _ => {
                self.scroll.fc.set_form(self.scroll.build_form());
                self.scroll.fc.set_backend_info(backend.line_height());
                let rect = Self::form_rect(area, backend);
                match self.scroll.fc.handle_cached(event, rect) {
                    FormControllerEvent::FormAction(FormEvent::ToggleChanged { id, value }) => {
                        if let Ok(idx) = id
                            .as_str()
                            .strip_prefix("toggle-")
                            .unwrap_or("")
                            .parse::<usize>()
                        {
                            if idx < self.scroll.toggles.len() {
                                self.scroll.toggles[idx] = value;
                            }
                        }
                        self.scroll.last_action = format!("{} = {value}", id.as_str());
                        Reaction::Redraw
                    }
                    FormControllerEvent::FormAction(other) => {
                        self.scroll.last_action = format!("{other:?}");
                        Reaction::Redraw
                    }
                    FormControllerEvent::ScrollChanged | FormControllerEvent::Consumed => {
                        Reaction::Redraw
                    }
                    FormControllerEvent::Ignored => Reaction::Continue,
                }
            }
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            0 => serde_json::to_value(build_all_fields_form()).unwrap_or(serde_json::Value::Null),
            1 => serde_json::json!({
                "form": self.search_replace.build_form(),
                "last_action": self.search_replace.last_action,
            }),
            _ => serde_json::json!({
                "scroll_offset": self.scroll.fc.scroll_offset(),
                "toggles_on": self.scroll.toggles.iter().filter(|&&v| v).count(),
                "last_action": self.scroll.last_action,
            }),
        }
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
All field kinds
Search & replace
Scrolling