1use teksilo_canvas::{Size, SizeProposal};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::binding::BindingLevel;
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::signal::Signal;
57use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
58use teksilo_core::widget_id::WidgetId;
59use teksilo_i18n::lit;
60use teksilo_i18n::tr_widget;
61use teksilo_telemetry::{
62 ConsentScope, ConsentState, ConsentStore, OpenedTelemetry, RemoteDataExport, TelemetryExt,
63 TelemetryMode, UsageReporter,
64};
65use teksilo_tokens::TextStyleRole;
66
67use crate::accordion::Accordion;
68use crate::button::{Button, ButtonVariant};
69use crate::message_box::{MessageBox, MessageBoxButtons, StandardButton};
70use crate::panel::Panel;
71use crate::primitives::{HStack, Spacer, TextWidget, VStack};
72use crate::toggle::Toggle;
73use teksilo_i18n::LocalizedString;
74
75pub struct PrivacySettings {
78 compact: bool,
82 show_identity_row: bool,
86 show_mode_switch: bool,
90 show_inspect: bool,
96 inspect_event_count: usize,
98 privacy_policy_url: Option<String>,
102 data_processor_name: Option<String>,
105
106 root_id: Option<WidgetId>,
108}
109
110impl Default for PrivacySettings {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116impl PrivacySettings {
117 pub fn new() -> Self {
119 Self {
120 compact: false,
121 show_identity_row: true,
122 show_mode_switch: true,
123 show_inspect: true,
124 inspect_event_count: 50,
125 privacy_policy_url: None,
126 data_processor_name: None,
127 root_id: None,
128 }
129 }
130
131 pub fn compact(mut self, compact: bool) -> Self {
134 self.compact = compact;
135 self
136 }
137
138 pub fn show_identity_row(mut self, show: bool) -> Self {
142 self.show_identity_row = show;
143 self
144 }
145
146 pub fn show_mode_switch(mut self, show: bool) -> Self {
150 self.show_mode_switch = show;
151 self
152 }
153
154 pub fn show_inspect(mut self, show: bool) -> Self {
157 self.show_inspect = show;
158 self
159 }
160
161 pub fn inspect_event_count(mut self, n: usize) -> Self {
164 self.inspect_event_count = n.max(1);
165 self
166 }
167
168 pub fn privacy_policy_url(mut self, url: impl Into<String>) -> Self {
172 self.privacy_policy_url = Some(url.into());
173 self
174 }
175
176 pub fn data_processor_name(mut self, name: impl Into<String>) -> Self {
179 self.data_processor_name = Some(name.into());
180 self
181 }
182}
183
184impl std::fmt::Debug for PrivacySettings {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.debug_struct("PrivacySettings")
187 .field("compact", &self.compact)
188 .field("show_identity_row", &self.show_identity_row)
189 .field("show_mode_switch", &self.show_mode_switch)
190 .finish()
191 }
192}
193
194impl Widget for PrivacySettings {
195 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
196 let Some(telemetry) = ctx.try_telemetry().cloned() else {
200 let placeholder = ctx.add(VStack::new().spacing(8.0).child(
201 TextWidget::new(tr_widget!(privacy_not_configured())).style(TextStyleRole::Body),
202 ));
203 self.root_id = Some(placeholder);
204 return vec![placeholder];
205 };
206
207 let consent_signal = telemetry.consent.state_signal();
210 consent_signal.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
211
212 let revision_signal = telemetry.reporter.recent_log_revision();
218 revision_signal.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
219
220 let state = consent_signal.get();
221 let supported = telemetry.reporter.supported_scopes();
222 let endpoint = telemetry.reporter.endpoint().to_string();
223 let pseudonymous = matches!(
224 telemetry.reporter.active_mode(),
225 TelemetryMode::Pseudonymous,
226 ) && telemetry.reporter.install_id().is_some();
227 let supports_mode_switch = telemetry.reporter.supports_mode_switch();
228 let processor = self
229 .data_processor_name
230 .clone()
231 .unwrap_or_else(|| "the application".to_string());
232
233 let scope_panel = build_scope_panel(ctx, &telemetry, &state, supported);
234 let column = VStack::new()
235 .spacing(if self.compact { 12.0 } else { 18.0 })
236 .child(self.build_heading())
237 .child(self.build_notice(&telemetry, &processor, &endpoint, pseudonymous))
238 .child(scope_panel)
239 .child(build_accept_reject(&telemetry, &endpoint))
240 .child_opt(
241 (self.show_identity_row && pseudonymous).then(|| build_identity_row(&telemetry)),
242 )
243 .child_opt(
244 (self.show_inspect && !self.compact)
245 .then(|| build_inspect_accordion(&telemetry, self.inspect_event_count)),
246 )
247 .child_opt(
248 (self.show_mode_switch && supports_mode_switch && !self.compact)
249 .then(|| build_mode_switch(&telemetry)),
250 )
251 .child(build_footer(&telemetry));
252
253 let id = ctx.add(column);
254 self.root_id = Some(id);
255 vec![id]
256 }
257
258 fn layout_response(
259 &self,
260 proposal: SizeProposal,
261 ctx: &LayoutContext,
262 ) -> teksilo_core::widget::LayoutResponse {
263 self.root_id
264 .and_then(|c| ctx.child_size(c, proposal))
265 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
266 .into()
267 }
268
269 fn place_children(
270 &self,
271 bounds: teksilo_canvas::Rect,
272 _proposal: SizeProposal,
273 children: &mut [WidgetPlacement],
274 _ctx: &LayoutContext,
275 ) {
276 for child in children.iter_mut() {
277 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
278 child.size = Size::new(bounds.width, bounds.height);
279 }
280 }
281
282 fn children(&self) -> Vec<WidgetId> {
283 self.root_id.map(|id| vec![id]).unwrap_or_default()
284 }
285
286 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
287 builder.set_role(teksilo_core::accesskit::Role::Group);
288 builder.set_name(tr_widget!(privacy_a11y_group_name()).resolve_now());
290 }
291}
292
293impl PrivacySettings {
294 fn build_heading(&self) -> TextWidget {
295 TextWidget::new(tr_widget!(privacy_heading())).style(TextStyleRole::BodyBold)
296 }
297
298 fn build_notice(
299 &self,
300 telemetry: &OpenedTelemetry,
301 processor: &str,
302 endpoint: &str,
303 pseudonymous: bool,
304 ) -> Panel {
305 let lawful_basis = if pseudonymous {
306 tr_widget!(privacy_notice_lawful_pseudonymous())
307 } else {
308 tr_widget!(privacy_notice_lawful_anonymous())
309 };
310 let retention_days = telemetry.policy.retention_days as i64;
311 let adapter = telemetry.reporter.adapter_name().to_string();
312 let processor_owned = processor.to_string();
313 let endpoint_owned = endpoint.to_string();
314 let processor_line = tr_widget!(privacy_notice_controller(
315 processor = processor_owned,
316 adapter = adapter,
317 endpoint = endpoint_owned,
318 ));
319
320 let mut notice = VStack::new()
321 .spacing(6.0)
322 .child(TextWidget::new(processor_line).style(TextStyleRole::Body))
323 .child(
324 TextWidget::new(tr_widget!(privacy_notice_purposes())).style(TextStyleRole::Body),
325 )
326 .child(TextWidget::new(lawful_basis).style(TextStyleRole::Body))
327 .child(
328 TextWidget::new(tr_widget!(privacy_notice_retention(days = retention_days)))
329 .style(TextStyleRole::Body),
330 )
331 .child(
332 TextWidget::new(tr_widget!(privacy_notice_withdrawal_right()))
333 .style(TextStyleRole::Body),
334 );
335 if let Some(url) = self.privacy_policy_url.clone() {
336 notice = notice.child(
337 TextWidget::new(tr_widget!(privacy_notice_policy_link(url = url)))
338 .style(TextStyleRole::Small),
339 );
340 }
341 Panel::new().padding(14.0_f32).child(notice)
342 }
343}
344
345fn build_scope_panel(
348 ctx: &mut BuildContext,
349 telemetry: &OpenedTelemetry,
350 state: &ConsentState,
351 supported: ConsentScope,
352) -> Panel {
353 let mut column = VStack::new().spacing(8.0).child(
354 TextWidget::new(tr_widget!(privacy_scope_section_heading())).style(TextStyleRole::BodyBold),
355 );
356
357 if supported.anonymous_metrics {
358 column = column.child(scope_row(
359 ctx,
360 tr_widget!(privacy_scope_anonymous_metrics_label()),
361 tr_widget!(privacy_scope_anonymous_metrics_description()),
362 current_value(state, |s| s.anonymous_metrics),
363 !matches!(state, ConsentState::Denied),
364 telemetry.consent.clone(),
365 telemetry.reporter.endpoint().to_string(),
366 |scope, v| scope.anonymous_metrics = v,
367 ));
368 }
369 if supported.crash_reports {
370 column = column.child(scope_row(
371 ctx,
372 tr_widget!(privacy_scope_crash_reports_label()),
373 tr_widget!(privacy_scope_crash_reports_description()),
374 current_value(state, |s| s.crash_reports),
375 !matches!(state, ConsentState::Denied),
376 telemetry.consent.clone(),
377 telemetry.reporter.endpoint().to_string(),
378 |scope, v| scope.crash_reports = v,
379 ));
380 }
381 if supported.feature_flags {
382 column = column.child(scope_row(
383 ctx,
384 tr_widget!(privacy_scope_feature_flags_label()),
385 tr_widget!(privacy_scope_feature_flags_description()),
386 current_value(state, |s| s.feature_flags),
387 !matches!(state, ConsentState::Denied),
388 telemetry.consent.clone(),
389 telemetry.reporter.endpoint().to_string(),
390 |scope, v| scope.feature_flags = v,
391 ));
392 }
393 Panel::new().padding(14.0_f32).child(column)
394}
395
396fn current_value(state: &ConsentState, f: impl FnOnce(&ConsentScope) -> bool) -> bool {
397 match state {
398 ConsentState::Granted(scope) => f(scope),
399 _ => false,
400 }
401}
402
403fn scope_row(
404 ctx: &mut BuildContext,
405 label: LocalizedString,
406 description: LocalizedString,
407 initial: bool,
408 enabled: bool,
409 consent: ConsentStore,
410 endpoint: String,
411 apply: impl Fn(&mut ConsentScope, bool) + 'static,
412) -> HStack {
413 let signal = Signal::new(initial);
418 let consent_for_observe = consent.clone();
419 let endpoint_for_observe = endpoint;
420 let handle = signal.observe(move |&new_value| {
424 let _ = consent_for_observe
425 .set_or_grant_scope(&endpoint_for_observe, |scope| apply(scope, new_value));
426 });
427 ctx.own_handle(handle);
428
429 HStack::new()
430 .spacing(12.0)
431 .child(
432 VStack::new()
433 .spacing(2.0)
434 .child(TextWidget::new(label.clone()).style(TextStyleRole::Body))
435 .child(TextWidget::new(description).style(TextStyleRole::Small)),
436 )
437 .child(Spacer::new())
438 .child(Toggle::new(signal).label(label).enabled(enabled))
439}
440
441fn build_accept_reject(telemetry: &OpenedTelemetry, endpoint: &str) -> HStack {
442 let supported = telemetry.reporter.supported_scopes();
443 let endpoint = endpoint.to_string();
444 let consent_for_reject = telemetry.consent.clone();
445 let consent_for_accept = telemetry.consent.clone();
446
447 let reject = Button::new(tr_widget!(privacy_btn_reject_all()))
448 .variant(ButtonVariant::Plain)
449 .on_activate_fn(move |_ctx| {
450 let _ = consent_for_reject.deny();
451 });
452 let accept = Button::new(tr_widget!(privacy_btn_accept_all()))
453 .variant(ButtonVariant::Filled)
454 .on_activate_fn(move |_ctx| {
455 let _ = consent_for_accept.grant(supported, &endpoint);
456 });
457
458 HStack::new()
459 .spacing(8.0)
460 .child(reject)
461 .child(Spacer::new())
462 .child(accept)
463}
464
465fn build_identity_row(telemetry: &OpenedTelemetry) -> Panel {
466 let install_id = telemetry
467 .install_id
468 .as_ref()
469 .map(|id| id.get())
470 .unwrap_or_else(|| "(none)".to_string());
471 let retention_days = telemetry.policy.retention_days as i64;
472
473 let consent_for_erase = telemetry.consent.clone();
474 let reporter_for_erase = telemetry.reporter.clone();
475 let erase = Button::new(tr_widget!(privacy_btn_erase()))
476 .variant(ButtonVariant::Plain)
477 .tooltip(tr_widget!(privacy_btn_erase_tooltip()))
478 .on_activate_fn(move |ctx| {
479 let consent = consent_for_erase.clone();
480 let reporter = reporter_for_erase.clone();
481 MessageBox::question(tr_widget!(privacy_confirm_erase_title()))
482 .text(tr_widget!(privacy_confirm_erase_text()))
483 .buttons(MessageBoxButtons::OkCancel)
484 .on_result(move |result, _ctx| {
485 if matches!(result.button, StandardButton::Ok) {
486 let _ = reporter.erase_remote_data();
487 let _ = reporter.discard_pending();
488 let _ = consent.withdraw();
489 }
490 })
491 .present(ctx);
492 });
493
494 let reporter_for_fetch = telemetry.reporter.clone();
495 let install_id_for_fetch = install_id.clone();
496 let fetch = Button::new(tr_widget!(privacy_btn_fetch()))
497 .variant(ButtonVariant::Filled)
498 .tooltip(tr_widget!(privacy_btn_fetch_tooltip()))
499 .on_activate_fn(move |ctx| {
500 match reporter_for_fetch.fetch_remote_data() {
501 Ok(export) => {
502 let event_count = export.events.len() as i64;
503 let json = serde_json_export_label(&export);
504
505 let suggested_name = format!(
512 "teksilo-export-{}.json",
513 sanitize_filename(&install_id_for_fetch)
514 );
515 use teksilo_platform::file_dialog::{
516 EventContextFileDialogExt, FileDialogRequest, FileDialogResult,
517 };
518 let request = FileDialogRequest::save_file()
519 .title("Save your data export as JSON")
520 .default_file_name(&suggested_name)
521 .add_filter("JSON", &["json"]);
522
523 let submit = ctx.save_file(request, move |result, ctx| match result {
528 FileDialogResult::Saved(Some(path)) => {
529 match std::fs::write(&path, json.as_bytes()) {
530 Ok(()) => {
531 let mut details = String::new();
532 for (n, ev) in export.events.iter().enumerate().take(20) {
533 if !details.is_empty() {
534 details.push('\n');
535 }
536 details.push_str(&format!("{}. {}", n + 1, ev.name));
537 }
538 if export.events.len() > 20 {
539 details.push_str(&format!(
540 "\n… and {} more.",
541 export.events.len() - 20
542 ));
543 }
544 MessageBox::information(tr_widget!(
545 privacy_fetch_success_title()
546 ))
547 .text(tr_widget!(privacy_fetch_success_text(
548 count = event_count
549 )))
550 .informative_text(lit!(format!(
551 "{}\n\n{details}",
552 tr_widget!(privacy_fetch_saved_to(
553 path = path.display().to_string()
554 ))
555 .resolve_now()
556 )))
557 .buttons(MessageBoxButtons::Ok)
558 .present(ctx);
559 }
560 Err(e) => {
561 MessageBox::warning(tr_widget!(privacy_fetch_error_title()))
562 .text(tr_widget!(privacy_fetch_write_error(
563 path = path.display().to_string(),
564 error = e.to_string(),
565 )))
566 .buttons(MessageBoxButtons::Ok)
567 .present(ctx);
568 }
569 }
570 }
571 FileDialogResult::Saved(None) => {
572 let mut details = String::new();
577 for (n, ev) in export.events.iter().enumerate().take(20) {
578 if !details.is_empty() {
579 details.push('\n');
580 }
581 details.push_str(&format!("{}. {}", n + 1, ev.name));
582 }
583 if export.events.len() > 20 {
584 details.push_str(&format!(
585 "\n… and {} more.",
586 export.events.len() - 20
587 ));
588 }
589 MessageBox::information(tr_widget!(privacy_fetch_success_title()))
590 .text(tr_widget!(privacy_fetch_success_text(count = event_count)))
591 .informative_text(lit!(details))
592 .detailed_text(lit!(json))
593 .buttons(MessageBoxButtons::Ok)
594 .present(ctx);
595 }
596 FileDialogResult::Error(msg) => {
597 MessageBox::warning(tr_widget!(privacy_fetch_error_title()))
598 .text(lit!(msg))
599 .buttons(MessageBoxButtons::Ok)
600 .present(ctx);
601 }
602 _ => {}
606 });
607 if let Err(msg) = submit {
608 MessageBox::warning(tr_widget!(privacy_fetch_error_title()))
609 .text(lit!(msg))
610 .buttons(MessageBoxButtons::Ok)
611 .present(ctx);
612 }
613 }
614 Err(e) => {
615 MessageBox::warning(tr_widget!(privacy_fetch_error_title()))
616 .text(lit!(format!("{e}")))
617 .buttons(MessageBoxButtons::Ok)
618 .present(ctx);
619 }
620 }
621 });
622
623 Panel::new().padding(14.0_f32).child(
624 VStack::new()
625 .spacing(6.0)
626 .child(
627 TextWidget::new(tr_widget!(privacy_identity_heading()))
628 .style(TextStyleRole::BodyBold),
629 )
630 .child(
631 TextWidget::new(tr_widget!(privacy_identity_install_id(
632 id = install_id.clone()
633 )))
634 .style(TextStyleRole::Small),
635 )
636 .child(
637 TextWidget::new(tr_widget!(privacy_identity_retention(
638 days = retention_days
639 )))
640 .style(TextStyleRole::Small),
641 )
642 .child(HStack::new().spacing(8.0).child(fetch).child(erase)),
643 )
644}
645
646fn build_mode_switch(telemetry: &OpenedTelemetry) -> Panel {
647 let active = telemetry.reporter.active_mode();
648 let (current_label, target_mode, target_label, target_blurb) = match active {
649 TelemetryMode::Anonymous => (
650 tr_widget!(privacy_mode_current_anonymous()),
651 TelemetryMode::Pseudonymous,
652 tr_widget!(privacy_btn_switch_to_pseudonymous()),
653 tr_widget!(privacy_mode_blurb_pseudonymous()),
654 ),
655 TelemetryMode::Pseudonymous => (
656 tr_widget!(privacy_mode_current_pseudonymous()),
657 TelemetryMode::Anonymous,
658 tr_widget!(privacy_btn_switch_to_anonymous()),
659 tr_widget!(privacy_mode_blurb_anonymous()),
660 ),
661 };
662
663 let reporter = telemetry.reporter.clone();
664 let consent = telemetry.consent.clone();
665 let install_id = telemetry.install_id.clone();
666
667 let switch_btn = Button::new(target_label)
668 .variant(ButtonVariant::Plain)
669 .on_activate_fn(move |ctx| {
670 let reporter = reporter.clone();
671 let consent = consent.clone();
672 let install_id = install_id.clone();
673 let leaving_pseudonymous =
674 matches!(reporter.active_mode(), TelemetryMode::Pseudonymous);
675 let body = if leaving_pseudonymous {
676 tr_widget!(privacy_confirm_mode_switch_leaving_pseudonymous())
677 } else {
678 tr_widget!(privacy_confirm_mode_switch_leaving_anonymous())
679 };
680 MessageBox::question(tr_widget!(privacy_confirm_mode_switch_title()))
681 .text(body)
682 .buttons(MessageBoxButtons::OkCancel)
683 .on_result(move |result, _ctx| {
684 if !matches!(result.button, StandardButton::Ok) {
685 return;
686 }
687 if leaving_pseudonymous {
688 let _ = reporter.erase_remote_data();
689 if let Some(id) = &install_id {
690 let _ = id.clear();
691 }
692 }
693 let _ = reporter.discard_pending();
694 let _ = consent.reset();
695 reporter.set_active_mode(target_mode);
696 })
697 .present(ctx);
698 });
699
700 Panel::new().padding(14.0_f32).child(
701 VStack::new()
702 .spacing(6.0)
703 .child(
704 TextWidget::new(tr_widget!(privacy_mode_heading())).style(TextStyleRole::BodyBold),
705 )
706 .child(TextWidget::new(current_label).style(TextStyleRole::Body))
707 .child(TextWidget::new(target_blurb).style(TextStyleRole::Small))
708 .child(HStack::new().child(switch_btn).child(Spacer::new())),
709 )
710}
711
712fn build_footer(telemetry: &OpenedTelemetry) -> HStack {
713 let consent = telemetry.consent.clone();
714 let withdraw = Button::new(tr_widget!(privacy_btn_withdraw()))
715 .variant(ButtonVariant::Plain)
716 .tooltip(tr_widget!(privacy_btn_withdraw_tooltip()))
717 .on_activate_fn(move |ctx| {
718 let consent = consent.clone();
719 MessageBox::question(tr_widget!(privacy_confirm_withdraw_title()))
720 .text(tr_widget!(privacy_confirm_withdraw_text()))
721 .buttons(MessageBoxButtons::OkCancel)
722 .on_result(move |result, _ctx| {
723 if matches!(result.button, StandardButton::Ok) {
724 let _ = consent.withdraw();
725 }
726 })
727 .present(ctx);
728 });
729 HStack::new().child(Spacer::new()).child(withdraw)
730}
731
732fn serde_json_export_label(export: &RemoteDataExport) -> String {
736 serde_json::to_string_pretty(export)
737 .unwrap_or_else(|e| format!("(failed to serialize export: {e})"))
738}
739
740fn sanitize_filename(s: &str) -> String {
745 s.chars()
746 .map(|c| {
747 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
748 c
749 } else {
750 '_'
751 }
752 })
753 .collect()
754}
755
756fn build_inspect_accordion(telemetry: &OpenedTelemetry, n: usize) -> Accordion {
761 use crate::primitives::Padding;
762 use teksilo_telemetry::{EventQueue, OwnedPropValue};
763
764 let recent = telemetry.recent_log.peek_recent(n);
765 let count = recent.len();
766 let count_i64 = count as i64;
767
768 let body = if count == 0 {
769 VStack::new()
770 .spacing(6.0)
771 .child(TextWidget::new(tr_widget!(privacy_inspect_empty())).style(TextStyleRole::Small))
772 } else {
773 let mut col = VStack::new().spacing(4.0).child(
774 TextWidget::new(tr_widget!(privacy_inspect_summary(count = count_i64)))
775 .style(TextStyleRole::Small),
776 );
777 for (idx, event) in recent.iter().enumerate() {
778 let mut props_summary = String::new();
779 for prop in event.props.iter().take(4) {
780 let v = match &prop.value {
781 OwnedPropValue::Str(s) => s.clone(),
782 OwnedPropValue::U32(n) => n.to_string(),
783 OwnedPropValue::I64(n) => n.to_string(),
784 OwnedPropValue::Bool(b) => b.to_string(),
785 OwnedPropValue::F64Bucket(b) => format!("{}-{}", b.min_x100, b.max_x100),
786 OwnedPropValue::HistogramStrU32(_) => "{histogram}".into(),
787 };
788 if !props_summary.is_empty() {
789 props_summary.push_str(", ");
790 }
791 props_summary.push_str(&format!("{}={v}", prop.key));
792 }
793 if event.props.len() > 4 {
794 props_summary.push_str(&format!(", … +{} more", event.props.len() - 4));
795 }
796 let line = if props_summary.is_empty() {
797 format!("{}. {}", idx + 1, event.name)
798 } else {
799 format!("{}. {} ({})", idx + 1, event.name, props_summary)
800 };
801 col = col.child(TextWidget::new(lit!(line)).style(TextStyleRole::Mono));
802 }
803 col
804 };
805
806 Accordion::new(
807 tr_widget!(privacy_inspect_title(count = count_i64)),
808 Signal::new(false),
809 )
810 .content(Padding::uniform(10.0).child(body))
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816 use teksilo_canvas::SizeProposal;
817 use teksilo_core::widget_tree::WidgetTree;
818 use teksilo_i18n::{
819 I18nConfig, I18nManager, LanguageIdentifier,
820 thread_local::{clear, install},
821 };
822
823 fn group_name(tree: &mut WidgetTree) -> String {
826 let update = tree.sync_accessibility();
827 let group = update
828 .nodes
829 .iter()
830 .find(|(_, n)| n.role() == teksilo_core::accesskit::Role::Group)
831 .expect("PrivacySettings emits a Role::Group container");
832 group.1.label().unwrap_or("").to_string()
833 }
834
835 #[test]
840 fn container_has_group_role_and_name() {
841 clear();
842 let cfg = I18nConfig::test_only("en-US", &[]).framework_locales(crate::framework_locales());
843 install(I18nManager::from_config(&cfg));
844
845 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
846 tree.add(PrivacySettings::new());
847 tree.layout(SizeProposal::exact(600.0, 400.0));
848
849 assert_eq!(group_name(&mut tree), "Privacy & Telemetry settings");
850 clear();
851 }
852
853 #[test]
858 fn a11y_name_is_locale_reactive() {
859 clear();
860 let cfg = I18nConfig::test_only("en-US", &[])
861 .with_locale("fr-FR", &[])
862 .framework_locales(crate::framework_locales());
863 let mgr = I18nManager::from_config(&cfg);
864 install(mgr.clone());
865
866 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
867 tree.add(PrivacySettings::new());
868 tree.layout(SizeProposal::exact(600.0, 400.0));
869 assert_eq!(group_name(&mut tree), "Privacy & Telemetry settings");
870
871 let fr: LanguageIdentifier = "fr-FR".parse().unwrap();
872 mgr.set_locale(fr);
873 tree.set_locale("fr-FR".to_string());
875 tree.layout(SizeProposal::exact(600.0, 400.0));
876
877 assert_eq!(
878 group_name(&mut tree),
879 "Paramètres de confidentialité et de télémétrie",
880 "container name must re-resolve on locale change"
881 );
882 clear();
883 }
884}