1#[cfg(test)]
34mod tests;
35
36use std::rc::Rc;
37use teksilo_i18n::lit;
38
39use teksilo_canvas::{Point, Rect, SizeProposal};
40use teksilo_core::accesskit::{Live, Role};
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::event::{EventResponse, WidgetEvent};
43use teksilo_core::signal::{Prop, Signal};
44use teksilo_core::styles::{
45 SharedTextInputStyle, TextInputStyle, TextInputStyleConfig, TextInputValidationLevel,
46 TextInputVariant,
47};
48use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
49use teksilo_core::widget_builder::WidgetBuilder;
50use teksilo_core::widget_id::WidgetId;
51use teksilo_tokens::{Alignment, TextRole, TextStyleRole};
52
53use crate::icon_button::{BuiltInIcons, IconButton};
54use crate::primitives::text_input_field::{TextInputField, ValidationFeedback, ValidationOutcome};
55use crate::primitives::validation_strip::ValidationStrip;
56use crate::primitives::{
57 Center, Expand, HStack, MinSize, Padding, Shrinkable, TextWidget, VStack, ZStack,
58};
59use crate::tooltip::{self, RichTooltipSource};
60
61pub use crate::primitives::text_input_field::{AtRevealPolicy, EchoMode};
65use teksilo_i18n::LocalizedString;
66
67const CAPS_LOCK_GLYPH: &str = "\u{21EA}";
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum RevealMode {
75 #[default]
79 Toggle,
80 Hold,
84 None,
87}
88
89pub struct PasswordField {
91 text: Signal<String>,
92 placeholder: LocalizedString,
93 label: LocalizedString,
94 enabled: Prop<bool>,
97 read_only: bool,
98 max_length: Option<usize>,
99 char_filter: Option<Rc<dyn Fn(char) -> bool>>,
100 validator: Option<Rc<dyn Fn(&str) -> ValidationOutcome>>,
101 on_submit: Option<Box<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
102 on_blur: Option<Box<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
103 min_width: Option<f32>,
104 variant: TextInputVariant,
105 style_override: Option<SharedTextInputStyle>,
106
107 echo_mode: EchoMode,
109 echo_char: char,
110 reveal_mode: RevealMode,
111 revealed: Option<Signal<bool>>,
112 allow_copy: bool,
113 caps_lock_warning: bool,
114 at_reveal_policy: AtRevealPolicy,
115
116 tooltip_text: Option<LocalizedString>,
118 rich_tooltip_source: Option<RichTooltipSource>,
119 composite_tooltip_content: Option<Box<dyn Widget>>,
120
121 revealed_signal: Signal<bool>,
123 root_child_id: Option<WidgetId>,
124}
125
126impl std::fmt::Debug for PasswordField {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct("PasswordField")
129 .field("label", &self.label)
130 .field("echo_mode", &self.echo_mode)
131 .field("reveal_mode", &self.reveal_mode)
132 .finish_non_exhaustive()
133 }
134}
135
136impl PasswordField {
137 pub fn new(password: Signal<String>) -> Self {
139 Self {
140 text: password,
141 placeholder: LocalizedString::literal(String::new()),
142 label: LocalizedString::literal(String::new()),
143 enabled: Prop::Static(true),
144 read_only: false,
145 max_length: None,
146 char_filter: None,
147 validator: None,
148 on_submit: None,
149 on_blur: None,
150 min_width: None,
151 variant: TextInputVariant::default(),
152 style_override: None,
153 echo_mode: EchoMode::Masked,
154 echo_char: '\u{2022}',
155 reveal_mode: RevealMode::Toggle,
156 revealed: None,
157 allow_copy: false,
158 caps_lock_warning: true,
159 at_reveal_policy: AtRevealPolicy::SwapRole,
160 tooltip_text: None,
161 rich_tooltip_source: None,
162 composite_tooltip_content: None,
163 revealed_signal: Signal::new(false),
164 root_child_id: None,
165 }
166 }
167
168 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
170 let ls: LocalizedString = text.into();
171 self.placeholder = ls;
172 self
173 }
174
175 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
178 let ls: LocalizedString = label.into();
179 self.label = ls;
180 self
181 }
182
183 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
187 self.enabled = enabled.into();
188 self
189 }
190
191 pub fn read_only(mut self, read_only: bool) -> Self {
193 self.read_only = read_only;
194 self
195 }
196
197 pub fn max_length(mut self, max_length: usize) -> Self {
199 self.max_length = Some(max_length);
200 self
201 }
202
203 pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
206 self.char_filter = Some(Rc::new(f));
207 self
208 }
209
210 pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self {
213 self.validator = Some(Rc::new(f));
214 self
215 }
216
217 pub fn on_submit_fn(
219 mut self,
220 f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
221 ) -> Self {
222 self.on_submit = Some(Box::new(f));
223 self
224 }
225
226 pub fn on_blur_fn(
228 mut self,
229 f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
230 ) -> Self {
231 self.on_blur = Some(Box::new(f));
232 self
233 }
234
235 pub fn min_width(mut self, width: f32) -> Self {
237 self.min_width = Some(width);
238 self
239 }
240
241 pub fn variant(mut self, variant: TextInputVariant) -> Self {
243 self.variant = variant;
244 self
245 }
246
247 pub fn style(mut self, style: impl TextInputStyle) -> Self {
249 self.style_override = Some(Rc::new(style));
250 self
251 }
252
253 pub fn echo_char(mut self, c: char) -> Self {
255 self.echo_char = c;
256 self
257 }
258
259 pub fn echo_mode(mut self, mode: EchoMode) -> Self {
261 self.echo_mode = mode;
262 self
263 }
264
265 pub fn reveal_mode(mut self, mode: RevealMode) -> Self {
267 self.reveal_mode = mode;
268 self
269 }
270
271 pub fn revealed(mut self, revealed: Signal<bool>) -> Self {
275 self.revealed = Some(revealed);
276 self
277 }
278
279 pub fn allow_copy(mut self, allow: bool) -> Self {
282 self.allow_copy = allow;
283 self
284 }
285
286 pub fn caps_lock_warning(mut self, on: bool) -> Self {
290 self.caps_lock_warning = on;
291 self
292 }
293
294 pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self {
297 self.at_reveal_policy = policy;
298 self
299 }
300
301 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
309 self.tooltip_text = Some(text.into());
310 self.rich_tooltip_source = None;
311 self.composite_tooltip_content = None;
312 self
313 }
314
315 pub fn rich_tooltip_key(mut self, key: impl Into<String>) -> Self {
319 self.rich_tooltip_source = Some(RichTooltipSource::Key(key.into()));
320 self.tooltip_text = None;
321 self.composite_tooltip_content = None;
322 self
323 }
324
325 pub fn rich_tooltip_content(mut self, content: tooltip::TooltipContent) -> Self {
331 self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
332 self.tooltip_text = None;
333 self.composite_tooltip_content = None;
334 self
335 }
336
337 pub fn rich_tooltip(mut self, content: tooltip::TooltipContent) -> Self {
343 self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
344 self.tooltip_text = None;
345 self.composite_tooltip_content = None;
346 self
347 }
348
349 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
353 self.composite_tooltip_content = Some(Box::new(content));
354 self.tooltip_text = None;
355 self.rich_tooltip_source = None;
356 self
357 }
358
359 pub fn revealed_signal(&self) -> Signal<bool> {
362 self.revealed
363 .clone()
364 .unwrap_or_else(|| self.revealed_signal.clone())
365 }
366
367 pub fn text(&self) -> Signal<String> {
369 self.text.clone()
370 }
371}
372
373impl Widget for PasswordField {
374 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
375 use crate::styles::recipe_text_input_style as field_dims;
376
377 let self_id = ctx.self_id();
378 ctx.enabled_when(self_id, self.enabled.clone());
379
380 let revealed = self
382 .revealed
383 .clone()
384 .unwrap_or_else(|| self.revealed_signal.clone());
385
386 let focused = ctx.signal(false);
390 let hovered = ctx.signal(false);
391
392 let inner_height =
393 (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
394 let text_area_height =
395 (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
396
397 let mut field = TextInputField::new(self.text.clone())
399 .enabled(self.enabled.clone())
400 .read_only(self.read_only)
401 .placeholder(self.placeholder.clone())
402 .text_height(text_area_height)
403 .secure(self.echo_mode)
404 .echo_char(self.echo_char)
405 .at_reveal_policy(self.at_reveal_policy)
406 .allow_copy(self.allow_copy)
407 .revealed(revealed.clone());
408 if let Some(max) = self.max_length {
409 field = field.max_length(max);
410 }
411 if let Some(f) = self.char_filter.take() {
412 field = field.char_filter(move |c| (f)(c));
413 }
414 if let Some(cb) = self.on_submit.take() {
415 field = field.on_submit_fn(move |ctx| (cb)(ctx));
416 }
417 if let Some(cb) = self.on_blur.take() {
418 field = field.on_blur_fn(move |ctx| (cb)(ctx));
419 }
420 if let Some(validator) = self.validator.take() {
421 field = field.validator(move |s| (validator)(s));
422 }
423 let inner_feedback = field.validation_feedback_signal();
424
425 let field_id = if self.label.resolve_now().is_empty() {
428 ctx.add(field)
429 } else {
430 ctx.add(field.access_label(self.label.clone()))
431 };
432
433 let padded_field = ctx.add(
434 Padding::new(
435 field_dims::TEXT_FIELD_PADDING_VERTICAL,
436 0.0,
437 field_dims::TEXT_FIELD_PADDING_VERTICAL,
438 0.0,
439 )
440 .child_id(field_id),
441 );
442
443 let text_column_id = if self.placeholder.resolve_now().is_empty() {
450 ctx.add(
451 Shrinkable::new().child(
452 Expand::horizontal()
453 .respect_intrinsic()
454 .child_id(padded_field),
455 ),
456 )
457 } else {
458 let ph = TextWidget::new(self.placeholder.clone())
459 .style(TextStyleRole::Body)
460 .color(TextRole::Secondary)
461 .single_line()
462 .a11y_hidden();
463 let ph_id = ctx.add(
468 Expand::new()
469 .respect_intrinsic()
470 .align_child(Alignment::CENTER_LEADING)
471 .child(ph),
472 );
473 let text_for_vis = self.text.clone();
474 let visible = text_for_vis.map(|t| t.is_empty());
475 ctx.visible_when(ph_id, visible);
476 ctx.add(
477 Shrinkable::new().child(
478 Expand::horizontal()
479 .respect_intrinsic()
480 .child(ZStack::new().add_child(ph_id).add_child(padded_field)),
481 ),
482 )
483 };
484
485 let mut row = HStack::new().spacing(4.0);
487 row = row.add_child(text_column_id);
488
489 if self.caps_lock_warning
491 && let Some(window) = ctx.window()
492 {
493 let caps = window.caps_lock().clone();
494 let warn = TextWidget::new(lit!(CAPS_LOCK_GLYPH))
495 .style(TextStyleRole::Body)
496 .color(TextRole::Secondary)
497 .single_line()
498 .access_role(Role::Status)
499 .access_live(Live::Polite)
500 .access_label(teksilo_i18n::tr_widget!(a11y_caps_lock_on()));
501 let warn_id = ctx.add(warn);
502 let visible = caps.zip(&focused).map(|(c, f)| *c && *f);
503 ctx.visible_when(warn_id, visible);
504 row = row.add_child(warn_id);
505 }
506
507 match self.reveal_mode {
509 RevealMode::Toggle => {
510 let reveal = IconButton::visibility_toggle(revealed.clone())
511 .embedded()
512 .focusable(true)
513 .access_label(teksilo_i18n::tr_widget!(a11y_password_reveal()));
514 row = row.add_child(ctx.add(reveal));
515 }
516 RevealMode::Hold => {
517 let icon = (BuiltInIcons::global().eye)();
518 let revealed_hold = revealed.clone();
519 let hold = MinSize::new(24.0, 24.0)
520 .child(Center::new().child(icon))
521 .on_pointer_event(move |event, ctx| match event {
522 WidgetEvent::PointerDown { .. } => {
523 revealed_hold.set(true);
524 ctx.request_frame();
525 EventResponse::Handled
526 }
527 WidgetEvent::PointerUp { .. } | WidgetEvent::PointerLeave => {
528 revealed_hold.set(false);
529 ctx.request_frame();
530 EventResponse::Handled
531 }
532 _ => EventResponse::Ignored,
533 })
534 .cursor(CursorIcon::Pointer)
535 .access_role(Role::Button)
536 .access_label(teksilo_i18n::tr_widget!(a11y_password_reveal()));
537 row = row.add_child(ctx.add(hold));
538 }
539 RevealMode::None => {}
540 }
541
542 let row_id = ctx.add(
543 row.focus_within(focused.clone())
544 .hover_within(hovered.clone()),
545 );
546
547 let effective_enabled = ctx.effective_enabled_signal(self_id);
549 let is_disabled = effective_enabled.map(|on| !*on);
550 let validation_level = inner_feedback.map(|fb| match fb {
551 ValidationFeedback::Invalid { .. } => TextInputValidationLevel::Error,
552 ValidationFeedback::Corrected { .. } => TextInputValidationLevel::Corrected,
553 ValidationFeedback::Pristine | ValidationFeedback::Valid => {
554 TextInputValidationLevel::None
555 }
556 });
557
558 let style: SharedTextInputStyle = self
559 .style_override
560 .clone()
561 .or_else(|| ctx.theme().style_slots.text_input.clone())
562 .unwrap_or_else(|| Rc::new(crate::styles::RecipeTextInputStyle::default()));
563
564 let cfg = TextInputStyleConfig {
565 editor: row_id,
566 is_focused: focused.clone(),
567 is_hovered: hovered.clone(),
568 is_disabled,
569 validation: validation_level,
570 variant: self.variant,
571 };
572 let chrome_id = style.make_body(&cfg, ctx);
573
574 let min_w = self.min_width.unwrap_or(65.0);
575 let frame_id =
576 ctx.add(MinSize::new(min_w, field_dims::TEXT_FIELD_HEIGHT).child_id(chrome_id));
577
578 let strip_id = ctx.add(ValidationStrip::new(inner_feedback));
580
581 ctx.access_described_by(field_id, strip_id);
584
585 let framed_id = ctx.add(Expand::horizontal().respect_intrinsic().child_id(frame_id));
590 let root_id = ctx.add(
591 VStack::new()
592 .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
593 .add_child(framed_id)
594 .add_child(strip_id),
595 );
596
597 if let Some(content) = self.composite_tooltip_content.take() {
599 let delay = ctx.theme().motion.tooltip_delay_heavy;
600 tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
601 } else if let Some(source) = self.rich_tooltip_source.take() {
602 let delay = ctx.theme().motion.tooltip_delay;
603 tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
604 } else if let Some(text) = self.tooltip_text.clone() {
605 let delay = ctx.theme().motion.tooltip_delay;
606 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
607 }
608
609 self.root_child_id = Some(root_id);
610 vec![root_id]
611 }
612
613 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
614 self.root_child_id
618 .and_then(|id| ctx.child_size(id, proposal))
619 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
620 .into()
621 }
622
623 fn place_children(
624 &self,
625 bounds: Rect,
626 _proposal: SizeProposal,
627 children: &mut [WidgetPlacement],
628 _ctx: &LayoutContext,
629 ) {
630 if let Some(p) = children.first_mut() {
631 p.origin = Point::new(bounds.x, bounds.y);
632 p.size = bounds.size();
633 }
634 }
635
636 fn children(&self) -> Vec<WidgetId> {
637 self.root_child_id.into_iter().collect()
638 }
639}