use std::cell::RefCell;
use std::collections::HashMap;
use std::panic::Location;
use crate::a11y::{A11y, Announce};
use crate::layout::v_stack;
use crate::theme::{ActiveTheme, ControlSize, Themeable};
use crate::traits::accessible::Accessible;
use crate::traits::control_sized::ControlSized;
use crate::traits::disableable::Disableable;
use gpui::{
AnyElement, App, Bounds, ElementId, FocusHandle, GlobalElementId, InspectorElementId,
InteractiveElement, IntoElement, LayoutId, ParentElement, Pixels, RenderOnce, Role,
SharedString, Styled, Window, div, prelude::FluentBuilder, rems,
};
#[derive(Clone, Default, PartialEq)]
pub struct FormContext {
pub disabled: bool,
pub name: Option<SharedString>,
pub focus_handle: Option<FocusHandle>,
}
impl std::fmt::Debug for FormContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FormContext")
.field("disabled", &self.disabled)
.field("name", &self.name)
.field("focus_handle", &self.focus_handle.is_some())
.finish()
}
}
impl FormContext {
pub fn new() -> Self {
Self::default()
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn focus_handle(mut self, handle: FocusHandle) -> Self {
self.focus_handle = Some(handle);
self
}
pub fn over(mut self, outer: &FormContext) -> Self {
self.disabled |= outer.disabled;
if self.name.is_none() {
self.name = outer.name.clone();
}
if self.focus_handle.is_none() {
self.focus_handle = outer.focus_handle.clone();
}
self
}
}
thread_local! {
static SCOPES: RefCell<Vec<FormContext>> = const { RefCell::new(Vec::new()) };
}
#[must_use = "the scope closes as soon as this guard is dropped"]
pub struct FormScope {
_private: (),
}
impl Drop for FormScope {
fn drop(&mut self) {
SCOPES.with(|scopes| {
scopes.borrow_mut().pop();
});
}
}
pub fn push(context: FormContext) -> FormScope {
SCOPES.with(|scopes| {
let mut scopes = scopes.borrow_mut();
let nested = match scopes.last() {
Some(outer) => context.over(outer),
None => context,
};
scopes.push(nested);
});
FormScope { _private: () }
}
pub fn scope<R>(context: FormContext, f: impl FnOnce() -> R) -> R {
let _guard = push(context);
f()
}
pub fn current() -> Option<FormContext> {
SCOPES.with(|scopes| scopes.borrow().last().cloned())
}
pub fn disabled_here(own: bool) -> bool {
own || SCOPES.with(|scopes| scopes.borrow().last().is_some_and(|scope| scope.disabled))
}
pub fn name_here() -> Option<SharedString> {
SCOPES.with(|scopes| scopes.borrow().last().and_then(|scope| scope.name.clone()))
}
pub fn focus_handle_here() -> Option<FocusHandle> {
SCOPES.with(|scopes| {
scopes
.borrow()
.last()
.and_then(|scope| scope.focus_handle.clone())
})
}
thread_local! {
static FIELD_FOCUS_HANDLES: RefCell<HashMap<ElementId, FocusHandle>> =
RefCell::new(HashMap::new());
}
pub fn field_focus_handle(id: &ElementId, cx: &mut App) -> FocusHandle {
FIELD_FOCUS_HANDLES.with(|handles| {
let mut handles = handles.borrow_mut();
handles
.entry(id.clone())
.or_insert_with(|| cx.focus_handle())
.clone()
})
}
pub fn clear_field_focus_handles() {
FIELD_FOCUS_HANDLES.with(|handles| handles.borrow_mut().clear());
}
pub struct WithFormContext {
context: FormContext,
child: AnyElement,
}
impl WithFormContext {
pub fn new(context: FormContext, child: impl IntoElement) -> Self {
Self {
context,
child: child.into_any_element(),
}
}
}
impl IntoElement for WithFormContext {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl gpui::Element for WithFormContext {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, ()) {
let _scope = push(self.context.clone());
(self.child.request_layout(window, cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut (),
window: &mut Window,
cx: &mut App,
) {
let _scope = push(self.context.clone());
self.child.prepaint(window, cx);
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut (),
_prepaint: &mut (),
window: &mut Window,
cx: &mut App,
) {
let _scope = push(self.context.clone());
self.child.paint(window, cx);
}
}
#[derive(IntoElement)]
pub struct Fieldset {
id: ElementId,
legend: Option<SharedString>,
description: Option<SharedString>,
error: Option<SharedString>,
disabled: bool,
size: ControlSize,
children: Vec<AnyElement>,
}
impl Fieldset {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
legend: None,
description: None,
error: None,
disabled: false,
size: ControlSize::default(),
children: Vec::new(),
}
}
pub fn legend(mut self, legend: impl Into<SharedString>) -> Self {
self.legend = Some(legend.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self
}
}
pub fn fieldset(id: impl Into<ElementId>) -> Fieldset {
Fieldset::new(id)
}
impl Disableable for Fieldset {
fn is_disabled(&self) -> bool {
self.disabled
}
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl ControlSized for Fieldset {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl ParentElement for Fieldset {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Accessible for Fieldset {
fn a11y(&self) -> A11y {
let a11y = A11y::new(Role::Group);
match &self.legend {
Some(legend) => a11y.name(legend.clone()),
None => a11y,
}
}
}
impl RenderOnce for Fieldset {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let a11y = self.a11y();
let theme = cx.theme();
let metrics = theme.control(self.size);
let disabled = disabled_here(self.disabled);
let legend = self.legend.map(|legend| {
div()
.text_size(metrics.text_size)
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(if disabled {
theme.fg_disabled()
} else {
theme.fg()
})
.child(legend)
});
let description = self.description.map(|description| {
div()
.text_xs()
.text_color(if disabled {
theme.fg_disabled()
} else {
theme.fg_muted()
})
.child(description)
});
let error = self
.error
.map(|error| div().text_xs().text_color(theme.danger()).child(error));
v_stack()
.id(self.id)
.announce(a11y)
.gap(rems(0.5))
.when_some(legend, |this, legend| this.child(legend))
.when_some(description, |this, description| this.child(description))
.child(WithFormContext::new(
FormContext::new().disabled(disabled),
v_stack().gap(rems(0.75)).children(self.children),
))
.when_some(error, |this, error| this.child(error))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::a11y::test_support::announced;
use crate::elements::checkbox::{Checkbox, checkbox};
use crate::elements::field::field;
use crate::traits::labelable::Labelable;
use gpui::{
AnyElement, AppContext, Bounds, Context, Modifiers, Render, TestAppContext,
VisualTestContext, px, size,
};
struct Harness {
build: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
}
impl Render for Harness {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
(self.build)(window, cx)
}
}
fn draw(
cx: &mut TestAppContext,
build: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> &'static mut VisualTestContext {
let window = cx.open_window(size(px(600.), px(400.)), move |_window, _cx| Harness {
build: Box::new(build),
});
let cx = VisualTestContext::from_window(*std::ops::Deref::deref(&window), cx).into_mut();
cx.run_until_parked();
cx
}
fn bounds(cx: &mut VisualTestContext, selector: &'static str) -> Bounds<Pixels> {
cx.debug_bounds(selector)
.unwrap_or_else(|| panic!("`{selector}` was never laid out"))
}
fn click(cx: &mut VisualTestContext, selector: &'static str) {
let target = bounds(cx, selector).center();
cx.simulate_click(target, Modifiers::default());
cx.run_until_parked();
}
#[test]
fn an_empty_stack_reads_as_nothing() {
assert_eq!(current(), None);
assert!(!disabled_here(false));
assert!(disabled_here(true), "a control's own flag still wins");
assert_eq!(name_here(), None);
assert!(focus_handle_here().is_none());
}
#[test]
fn a_scope_is_visible_inside_it_and_not_after_it() {
scope(FormContext::new().disabled(true).name("Street"), || {
assert!(disabled_here(false));
assert_eq!(name_here().as_deref(), Some("Street"));
});
assert!(!disabled_here(false));
assert_eq!(name_here(), None);
}
#[test]
fn nesting_cannot_re_enable() {
scope(FormContext::new().disabled(true), || {
scope(FormContext::new().disabled(false), || {
assert!(
disabled_here(false),
"an inner scope saying nothing about disabled must not re-enable"
);
});
});
}
#[test]
fn a_name_is_inherited_until_something_nearer_answers() {
scope(FormContext::new().name("outer"), || {
scope(FormContext::new(), || {
assert_eq!(name_here().as_deref(), Some("outer"));
});
scope(FormContext::new().name("inner"), || {
assert_eq!(name_here().as_deref(), Some("inner"));
});
});
}
#[test]
fn the_guard_closes_the_scope_through_a_panic() {
let panicked = std::panic::catch_unwind(|| {
scope(FormContext::new().disabled(true), || {
panic!("a child blew up mid-layout");
})
});
assert!(panicked.is_err());
assert_eq!(current(), None, "the guard popped the scope anyway");
assert!(!disabled_here(false));
}
#[gpui::test]
fn a_fieldset_announces_its_legend_as_a_group(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let cx = cx.add_empty_window();
let announced = cx.update(|window, cx| {
announced(fieldset("billing").legend("Billing address"), window, cx)
});
assert_eq!(announced.role, Some(Role::Group));
assert_eq!(announced.name(), Some("Billing address"));
assert_eq!(announced.id, Some(ElementId::Name("billing".into())));
}
#[gpui::test]
fn a_fieldset_without_a_legend_still_announces(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let cx = cx.add_empty_window();
let announced = cx.update(|window, cx| announced(fieldset("billing"), window, cx));
assert_eq!(announced.role, Some(Role::Group));
assert_eq!(announced.name(), None);
}
#[gpui::test]
fn a_group_disables_a_checkbox_that_says_nothing_about_it(cx: &mut TestAppContext) {
cx.update(crate::init);
let (enabled, disabled) = cx.update(|cx| {
(
cx.new(|_cx| checkbox("consent-open", false).label("Consent")),
cx.new(|_cx| checkbox("consent-locked", false).label("Consent")),
)
});
let (loose, grouped) = (enabled.clone(), disabled.clone());
let cx = draw(cx, move |_window, _cx| {
v_stack()
.child(
fieldset("open")
.legend("Open")
.child(field("consent-open").label("Consent").child(loose.clone())),
)
.child(
fieldset("locked").legend("Locked").disabled(true).child(
field("consent-locked")
.label("Consent")
.child(grouped.clone()),
),
)
.into_any_element()
});
let hit = |handle: &gpui::Entity<Checkbox>, cx: &mut VisualTestContext| {
cx.update(|_window, cx| handle.read(cx).is_checked())
};
click(cx, r#"gpuikit-checkbox-Name("consent-open")"#);
assert!(
hit(&enabled, cx),
"the checkbox outside the disabled group still toggles"
);
click(cx, r#"gpuikit-checkbox-Name("consent-locked")"#);
assert!(
!hit(&disabled, cx),
"a checkbox inside a disabled fieldset must not toggle, even though neither it \
nor its field says `disabled`"
);
}
#[gpui::test]
fn a_click_on_a_field_label_focuses_the_control_it_names(cx: &mut TestAppContext) {
cx.update(crate::init);
let control = cx.update(|cx| cx.new(|_cx| checkbox("consent", false).label("Consent")));
let drawn = control.clone();
let cx = draw(cx, move |_window, _cx| {
fieldset("billing")
.legend("Billing address")
.child(field("street").label("Street").child(drawn.clone()))
.into_any_element()
});
assert!(
cx.update(|window, cx| window.focused(cx)).is_none(),
"nothing is focused before the click"
);
click(cx, "gpuikit-field-label");
let focused = cx.update(|window, cx| window.focused(cx));
let expected =
cx.update(|_window, cx| field_focus_handle(&ElementId::Name("street".into()), cx));
assert_eq!(
focused,
Some(expected),
"the label click focused the handle the field published and the checkbox tracked"
);
assert!(
!control.read_with(cx, |checkbox, _| checkbox.is_checked()),
"focusing a control is not operating it"
);
}
#[gpui::test]
fn a_field_focus_handle_is_stable_per_id(cx: &mut TestAppContext) {
let (first, second, other) = cx.update(|cx| {
let id = ElementId::Name("street".into());
let other = ElementId::Name("city".into());
(
field_focus_handle(&id, cx),
field_focus_handle(&id, cx),
field_focus_handle(&other, cx),
)
});
assert_eq!(first, second);
assert_ne!(first, other);
}
#[gpui::test]
fn clearing_field_focus_handles_mints_fresh_ones(cx: &mut TestAppContext) {
let id = ElementId::Name("clear_field_focus_handles_test".into());
let before = cx.update(|cx| field_focus_handle(&id, cx));
assert_eq!(
before,
cx.update(|cx| field_focus_handle(&id, cx)),
"the handle is stable until cleared"
);
clear_field_focus_handles();
let after = cx.update(|cx| field_focus_handle(&id, cx));
assert_ne!(
before, after,
"clearing evicts the cache, so the same id mints a new handle"
);
}
}