1#[cfg(test)]
43mod tests;
44
45use std::rc::Rc;
46use teksilo_i18n::lit;
47
48use teksilo_canvas::{Point, Rect, SizeProposal};
49use teksilo_core::accesskit::{Live, Role};
50use teksilo_core::build_context::BuildContext;
51use teksilo_core::event::{EventResponse, WidgetEvent};
52use teksilo_core::signal::{Prop, Signal};
53use teksilo_core::styles::{
54 SharedTextInputStyle, TextInputStyle, TextInputStyleConfig, TextInputValidationLevel,
55 TextInputVariant,
56};
57use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
58use teksilo_core::widget_builder::WidgetBuilder;
59use teksilo_core::widget_id::WidgetId;
60use teksilo_tokens::{Alignment, TextRole, TextStyleRole};
61
62use crate::icon_button::{BuiltInIcons, IconButton};
63use crate::primitives::text_input_field::{TextInputField, ValidationFeedback, ValidationOutcome};
64use crate::primitives::validation_strip::ValidationStrip;
65use crate::primitives::{
66 Center, Expand, HStack, MinSize, Padding, Shrinkable, TextWidget, VStack, ZStack,
67};
68use crate::tooltip::{self, RichTooltipSource};
69
70pub use crate::primitives::text_input_field::{AtRevealPolicy, EchoMode};
74use teksilo_i18n::LocalizedString;
75
76const CAPS_LOCK_GLYPH: &str = "\u{21EA}";
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum RevealMode {
84 #[default]
88 Toggle,
89 Hold,
93 None,
96}
97
98pub struct PasswordField {
100 text: Signal<String>,
101 placeholder: LocalizedString,
102 label: LocalizedString,
103 enabled: Prop<bool>,
106 read_only: bool,
107 max_length: Option<usize>,
108 char_filter: Option<Rc<dyn Fn(char) -> bool>>,
109 validator: Option<Rc<dyn Fn(&str) -> ValidationOutcome>>,
110 on_submit: Option<Box<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
111 on_blur: Option<Box<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
112 min_width: Option<f32>,
113 variant: TextInputVariant,
114 style_override: Option<SharedTextInputStyle>,
115
116 echo_mode: EchoMode,
118 echo_char: char,
119 reveal_mode: RevealMode,
120 revealed: Option<Signal<bool>>,
121 allow_copy: bool,
122 caps_lock_warning: bool,
123 at_reveal_policy: AtRevealPolicy,
124
125 tooltip_text: Option<LocalizedString>,
127 rich_tooltip_source: Option<RichTooltipSource>,
128 composite_tooltip_content: Option<Box<dyn Widget>>,
129
130 revealed_signal: Signal<bool>,
132 root_child_id: Option<WidgetId>,
133}
134
135impl std::fmt::Debug for PasswordField {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.debug_struct("PasswordField")
138 .field("label", &self.label)
139 .field("echo_mode", &self.echo_mode)
140 .field("reveal_mode", &self.reveal_mode)
141 .finish_non_exhaustive()
142 }
143}
144
145impl PasswordField {
146 pub fn new(password: Signal<String>) -> Self {
148 Self {
149 text: password,
150 placeholder: LocalizedString::literal(String::new()),
151 label: LocalizedString::literal(String::new()),
152 enabled: Prop::Static(true),
153 read_only: false,
154 max_length: None,
155 char_filter: None,
156 validator: None,
157 on_submit: None,
158 on_blur: None,
159 min_width: None,
160 variant: TextInputVariant::default(),
161 style_override: None,
162 echo_mode: EchoMode::Masked,
163 echo_char: '\u{2022}',
164 reveal_mode: RevealMode::Toggle,
165 revealed: None,
166 allow_copy: false,
167 caps_lock_warning: true,
168 at_reveal_policy: AtRevealPolicy::SwapRole,
169 tooltip_text: None,
170 rich_tooltip_source: None,
171 composite_tooltip_content: None,
172 revealed_signal: Signal::new(false),
173 root_child_id: None,
174 }
175 }
176
177 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
179 let ls: LocalizedString = text.into();
180 self.placeholder = ls;
181 self
182 }
183
184 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
187 let ls: LocalizedString = label.into();
188 self.label = ls;
189 self
190 }
191
192 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
196 self.enabled = enabled.into();
197 self
198 }
199
200 pub fn read_only(mut self, read_only: bool) -> Self {
202 self.read_only = read_only;
203 self
204 }
205
206 pub fn max_length(mut self, max_length: usize) -> Self {
208 self.max_length = Some(max_length);
209 self
210 }
211
212 pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
215 self.char_filter = Some(Rc::new(f));
216 self
217 }
218
219 pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self {
222 self.validator = Some(Rc::new(f));
223 self
224 }
225
226 pub fn on_submit_fn(
228 mut self,
229 f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
230 ) -> Self {
231 self.on_submit = Some(Box::new(f));
232 self
233 }
234
235 pub fn on_blur_fn(
237 mut self,
238 f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
239 ) -> Self {
240 self.on_blur = Some(Box::new(f));
241 self
242 }
243
244 pub fn min_width(mut self, width: f32) -> Self {
246 self.min_width = Some(width);
247 self
248 }
249
250 pub fn variant(mut self, variant: TextInputVariant) -> Self {
252 self.variant = variant;
253 self
254 }
255
256 pub fn style(mut self, style: impl TextInputStyle) -> Self {
258 self.style_override = Some(Rc::new(style));
259 self
260 }
261
262 pub fn echo_char(mut self, c: char) -> Self {
264 self.echo_char = c;
265 self
266 }
267
268 pub fn echo_mode(mut self, mode: EchoMode) -> Self {
270 self.echo_mode = mode;
271 self
272 }
273
274 pub fn reveal_mode(mut self, mode: RevealMode) -> Self {
276 self.reveal_mode = mode;
277 self
278 }
279
280 pub fn revealed(mut self, revealed: Signal<bool>) -> Self {
284 self.revealed = Some(revealed);
285 self
286 }
287
288 pub fn allow_copy(mut self, allow: bool) -> Self {
291 self.allow_copy = allow;
292 self
293 }
294
295 pub fn caps_lock_warning(mut self, on: bool) -> Self {
299 self.caps_lock_warning = on;
300 self
301 }
302
303 pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self {
306 self.at_reveal_policy = policy;
307 self
308 }
309
310 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
318 self.tooltip_text = Some(text.into());
319 self.rich_tooltip_source = None;
320 self.composite_tooltip_content = None;
321 self
322 }
323
324 pub fn rich_tooltip_key(mut self, key: impl Into<String>) -> Self {
328 self.rich_tooltip_source = Some(RichTooltipSource::Key(key.into()));
329 self.tooltip_text = None;
330 self.composite_tooltip_content = None;
331 self
332 }
333
334 pub fn rich_tooltip_content(mut self, content: tooltip::TooltipContent) -> Self {
340 self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
341 self.tooltip_text = None;
342 self.composite_tooltip_content = None;
343 self
344 }
345
346 pub fn rich_tooltip(mut self, content: tooltip::TooltipContent) -> Self {
352 self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
353 self.tooltip_text = None;
354 self.composite_tooltip_content = None;
355 self
356 }
357
358 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
362 self.composite_tooltip_content = Some(Box::new(content));
363 self.tooltip_text = None;
364 self.rich_tooltip_source = None;
365 self
366 }
367
368 pub fn revealed_signal(&self) -> Signal<bool> {
371 self.revealed
372 .clone()
373 .unwrap_or_else(|| self.revealed_signal.clone())
374 }
375
376 pub fn text(&self) -> Signal<String> {
378 self.text.clone()
379 }
380}
381
382impl Widget for PasswordField {
383 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
384 use crate::styles::recipe_text_input_style as field_dims;
385
386 let self_id = ctx.self_id();
387 ctx.enabled_when(self_id, self.enabled.clone());
388
389 let revealed = self
391 .revealed
392 .clone()
393 .unwrap_or_else(|| self.revealed_signal.clone());
394
395 let focused = ctx.signal(false);
399 let hovered = ctx.signal(false);
400
401 let inner_height =
402 (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
403 let text_area_height =
404 (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
405
406 let mut field = TextInputField::new(self.text.clone())
408 .enabled(self.enabled.clone())
409 .read_only(self.read_only)
410 .placeholder(self.placeholder.clone())
411 .text_height(text_area_height)
412 .secure(self.echo_mode)
413 .echo_char(self.echo_char)
414 .at_reveal_policy(self.at_reveal_policy)
415 .allow_copy(self.allow_copy)
416 .revealed(revealed.clone());
417 if let Some(max) = self.max_length {
418 field = field.max_length(max);
419 }
420 if let Some(f) = self.char_filter.take() {
421 field = field.char_filter(move |c| (f)(c));
422 }
423 if let Some(cb) = self.on_submit.take() {
424 field = field.on_submit_fn(move |ctx| (cb)(ctx));
425 }
426 if let Some(cb) = self.on_blur.take() {
427 field = field.on_blur_fn(move |ctx| (cb)(ctx));
428 }
429 if let Some(validator) = self.validator.take() {
430 field = field.validator(move |s| (validator)(s));
431 }
432 let inner_feedback = field.validation_feedback_signal();
433
434 let field_id = if self.label.resolve_now().is_empty() {
437 ctx.add(field)
438 } else {
439 ctx.add(field.access_label(self.label.clone()))
440 };
441
442 let padded_field = ctx.add(
443 Padding::new(
444 field_dims::TEXT_FIELD_PADDING_VERTICAL,
445 0.0,
446 field_dims::TEXT_FIELD_PADDING_VERTICAL,
447 0.0,
448 )
449 .child(field_id),
450 );
451
452 let text_column_id = if self.placeholder.resolve_now().is_empty() {
459 ctx.add(
460 Shrinkable::new()
461 .child(Expand::horizontal().respect_intrinsic().child(padded_field)),
462 )
463 } else {
464 let ph = TextWidget::new(self.placeholder.clone())
465 .style(TextStyleRole::Body)
466 .color(TextRole::Secondary)
467 .single_line()
468 .a11y_hidden();
469 let ph_id = ctx.add(
474 Expand::new()
475 .respect_intrinsic()
476 .align_child(Alignment::CENTER_LEADING)
477 .child(ph),
478 );
479 let text_for_vis = self.text.clone();
480 let visible = text_for_vis.map(|t| t.is_empty());
481 ctx.visible_when(ph_id, visible);
482 ctx.add(
483 Shrinkable::new().child(
484 Expand::horizontal()
485 .respect_intrinsic()
486 .child(ZStack::new().child(ph_id).child(padded_field)),
487 ),
488 )
489 };
490
491 let mut row = HStack::new().spacing(4.0);
493 row = row.child(text_column_id);
494
495 if self.caps_lock_warning
497 && let Some(window) = ctx.window()
498 {
499 let caps = window.caps_lock().clone();
500 let warn = TextWidget::new(lit!(CAPS_LOCK_GLYPH))
501 .style(TextStyleRole::Body)
502 .color(TextRole::Secondary)
503 .single_line()
504 .access_role(Role::Status)
505 .access_live(Live::Polite)
506 .access_label(teksilo_i18n::tr_widget!(a11y_caps_lock_on()));
507 let warn_id = ctx.add(warn);
508 let visible = caps.zip(&focused).map(|(c, f)| *c && *f);
509 ctx.visible_when(warn_id, visible);
510 row = row.child(warn_id);
511 }
512
513 match self.reveal_mode {
515 RevealMode::Toggle => {
516 let reveal = IconButton::visibility_toggle(revealed.clone())
517 .embedded()
518 .focusable(true)
519 .access_label(teksilo_i18n::tr_widget!(a11y_password_reveal()));
520 row = row.child(ctx.add(reveal));
521 }
522 RevealMode::Hold => {
523 let icon = (BuiltInIcons::global().eye)();
524 let revealed_hold = revealed.clone();
525 let reveal_hit = teksilo_core::styles::density::density_min_size(
526 teksilo_canvas::Size::new(24.0, 24.0),
527 teksilo_tokens::TargetAxes::BOTH,
528 &ctx.theme().input,
529 );
530 let hold = MinSize::new(reveal_hit.width, reveal_hit.height)
531 .child(Center::new().child(icon))
532 .on_pointer_event(move |event, ctx| match event {
533 WidgetEvent::PointerDown { .. } => {
534 revealed_hold.set(true);
535 ctx.request_frame();
536 EventResponse::Handled
537 }
538 WidgetEvent::PointerUp { .. } | WidgetEvent::PointerLeave { .. } => {
539 revealed_hold.set(false);
540 ctx.request_frame();
541 EventResponse::Handled
542 }
543 _ => EventResponse::Ignored,
544 })
545 .cursor(CursorIcon::Pointer)
546 .access_role(Role::Button)
547 .access_label(teksilo_i18n::tr_widget!(a11y_password_reveal()));
548 row = row.child(ctx.add(hold));
549 }
550 RevealMode::None => {}
551 }
552
553 let row_id = ctx.add(
554 row.focus_within(focused.clone())
555 .hover_within(hovered.clone()),
556 );
557
558 let effective_enabled = ctx.effective_enabled_signal(self_id);
560 let is_disabled = effective_enabled.map(|on| !*on);
561 let validation_level = inner_feedback.map(|fb| match fb {
562 ValidationFeedback::Invalid { .. } => TextInputValidationLevel::Error,
563 ValidationFeedback::Corrected { .. } => TextInputValidationLevel::Corrected,
564 ValidationFeedback::Pristine | ValidationFeedback::Valid => {
565 TextInputValidationLevel::None
566 }
567 });
568
569 let style: SharedTextInputStyle = self
570 .style_override
571 .clone()
572 .or_else(|| ctx.theme().style_slots.text_input.clone())
573 .unwrap_or_else(|| {
574 Rc::new(crate::styles::RecipeTextInputStyle::for_tokens(
575 &ctx.theme().input,
576 ))
577 });
578
579 let cfg = TextInputStyleConfig {
580 editor: row_id,
581 is_focused: focused.clone(),
582 is_hovered: hovered.clone(),
583 is_disabled,
584 validation: validation_level,
585 variant: self.variant,
586 min_height: None,
587 };
588 let chrome_id = style.make_body(&cfg, ctx);
589
590 let min_w = self.min_width.unwrap_or(65.0);
591 let frame_id = ctx.add(
592 MinSize::new(
593 min_w,
594 crate::styles::TextInputRecipe::for_tokens(&ctx.theme().input).height,
595 )
596 .child(chrome_id),
597 );
598
599 let strip_id = ctx.add(ValidationStrip::new(inner_feedback));
601
602 ctx.access_described_by(field_id, strip_id);
607
608 let framed_id = ctx.add(Expand::horizontal().respect_intrinsic().child(frame_id));
613 let root_id = ctx.add(
614 VStack::new()
615 .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
616 .child(framed_id)
617 .child(strip_id),
618 );
619
620 if let Some(content) = self.composite_tooltip_content.take() {
622 let delay = ctx.theme().motion.tooltip_delay_heavy;
623 tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
624 } else if let Some(source) = self.rich_tooltip_source.take() {
625 let delay = ctx.theme().motion.tooltip_delay;
626 tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
627 } else if let Some(text) = self.tooltip_text.clone() {
628 let delay = ctx.theme().motion.tooltip_delay;
629 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
630 }
631
632 self.root_child_id = Some(root_id);
633 vec![root_id]
634 }
635
636 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
637 self.root_child_id
641 .and_then(|id| ctx.child_size(id, proposal))
642 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
643 .into()
644 }
645
646 fn place_children(
647 &self,
648 bounds: Rect,
649 _proposal: SizeProposal,
650 children: &mut [WidgetPlacement],
651 _ctx: &LayoutContext,
652 ) {
653 if let Some(p) = children.first_mut() {
654 p.origin = Point::new(bounds.x, bounds.y);
655 p.size = bounds.size();
656 }
657 }
658
659 fn children(&self) -> Vec<WidgetId> {
660 self.root_child_id.into_iter().collect()
661 }
662}