1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2024-12-22 22:38:08 +00:00

refactor(core/ui): alternative multipage footer

This commit is contained in:
Martin Milata 2024-08-26 17:58:29 +02:00
parent 2572705d1f
commit 7db1529533
11 changed files with 376 additions and 160 deletions

View File

@ -35,6 +35,10 @@ impl<T: Component + Paginate> SwipePage<T> {
current: 0,
}
}
pub fn inner(&self) -> &T {
&self.inner
}
}
impl<T: Component + Paginate> Component for SwipePage<T> {

View File

@ -0,0 +1,101 @@
use crate::{
strutil::TString,
translations::TR,
ui::{
component::{swipe_detect::SwipeSettings, Component, SwipeDirection},
flow::{Swipable, SwipePage},
},
};
use super::{
Frame, FrameMsg, InternallySwipable as _, PagedVerticalMenu, SwipeContent,
VerticalMenuChoiceMsg,
};
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum ConfirmFido {
Intro,
ChooseCredential,
Details,
Tap,
Menu,
}
/// Wrapper that updates `Footer` content whenever page is changed.
pub struct ChooseCredential<F: Fn(usize) -> TString<'static>>(
Frame<SwipeContent<SwipePage<PagedVerticalMenu<F>>>>,
);
impl<F: Fn(usize) -> TString<'static>> ChooseCredential<F> {
pub fn new(label_fn: F, num_accounts: usize) -> Self {
let content_choose_credential = Frame::left_aligned(
TR::fido__title_select_credential.into(),
SwipeContent::new(SwipePage::vertical(PagedVerticalMenu::new(
num_accounts,
label_fn,
))),
)
.with_subtitle(TR::fido__title_for_authentication.into())
.with_menu_button()
.with_footer_page_hint(
TR::fido__more_credentials.into(),
TR::buttons__go_back.into(),
TR::instructions__swipe_up.into(),
TR::instructions__swipe_down.into(),
)
.with_swipe(SwipeDirection::Down, SwipeSettings::default())
.with_swipe(SwipeDirection::Right, SwipeSettings::immediate())
.with_vertical_pages();
Self(content_choose_credential)
}
}
impl<F: Fn(usize) -> TString<'static>> Component for ChooseCredential<F> {
type Msg = FrameMsg<VerticalMenuChoiceMsg>;
fn place(&mut self, bounds: crate::ui::geometry::Rect) -> crate::ui::geometry::Rect {
self.0.place(bounds)
}
fn event(
&mut self,
ctx: &mut crate::ui::component::EventCtx,
event: crate::ui::component::Event,
) -> Option<Self::Msg> {
let msg = self.0.event(ctx, event);
let current_page = self.0.inner().inner().inner().current_page();
self.0.update_footer_counter(
ctx,
current_page,
Some(self.0.inner().inner().inner().num_pages()),
);
msg
}
fn paint(&mut self) {
self.0.paint()
}
fn render<'s>(&'s self, target: &mut impl crate::ui::shape::Renderer<'s>) {
self.0.render(target)
}
}
impl<F: Fn(usize) -> TString<'static>> Swipable for ChooseCredential<F> {
fn get_swipe_config(&self) -> crate::ui::component::swipe_detect::SwipeConfig {
self.0.get_swipe_config()
}
fn get_internal_page_count(&self) -> usize {
self.0.get_internal_page_count()
}
}
#[cfg(feature = "ui_debug")]
impl<F: Fn(usize) -> TString<'static>> crate::trace::Trace for ChooseCredential<F> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
self.0.trace(t)
}
}

View File

@ -22,8 +22,7 @@ use crate::{
#[derive(Clone)]
pub struct Footer<'a> {
area: Rect,
instruction: TString<'a>,
content: Option<FooterContent<'a>>,
content: FooterContent<'a>,
swipe_allow_up: bool,
swipe_allow_down: bool,
progress: i16,
@ -32,8 +31,10 @@ pub struct Footer<'a> {
#[derive(Clone)]
enum FooterContent<'a> {
Description(TString<'a>),
Instruction(TString<'a>),
InstructionDescription(TString<'a>, TString<'a>),
PageCounter(PageCounter),
PageHint(PageHint),
}
impl<'a> Footer<'a> {
@ -45,14 +46,10 @@ impl<'a> Footer<'a> {
const STYLE_INSTRUCTION: &'static TextStyle = &theme::TEXT_SUB_GREY;
const STYLE_DESCRIPTION: &'static TextStyle = &theme::TEXT_SUB_GREY_LIGHT;
pub fn new<T: Into<TString<'a>>>(
instruction: T,
description: Option<TString<'static>>,
) -> Self {
fn from_content(content: FooterContent<'a>) -> Self {
Self {
area: Rect::zero(),
instruction: instruction.into(),
content: description.map(FooterContent::Description),
content,
swipe_allow_down: false,
swipe_allow_up: false,
progress: 0,
@ -60,38 +57,79 @@ impl<'a> Footer<'a> {
}
}
pub fn with_page_counter(self, max_pages: u8) -> Self {
Self {
content: Some(FooterContent::PageCounter(PageCounter::new(max_pages))),
..self
}
pub fn new<T: Into<TString<'a>>>(
instruction: T,
description: Option<TString<'static>>,
) -> Self {
let instruction = instruction.into();
Self::from_content(
description
.map(|d| FooterContent::InstructionDescription(instruction, d))
.unwrap_or(FooterContent::Instruction(instruction)),
)
}
pub fn update_instruction<T: Into<TString<'a>>>(&mut self, ctx: &mut EventCtx, s: T) {
self.instruction = s.into();
pub fn with_page_counter(max_pages: u8, instruction: TString<'static>) -> Self {
Self::from_content(FooterContent::PageCounter(PageCounter::new(
max_pages,
instruction,
)))
}
pub fn with_page_hint(
description: TString<'static>,
description_last: TString<'static>,
instruction: TString<'static>,
instruction_last: TString<'static>,
) -> Self {
Self::from_content(FooterContent::PageHint(PageHint {
description,
description_last,
instruction,
instruction_last,
page_curr: 0,
page_num: 1,
}))
}
pub fn update_instruction<T: Into<TString<'static>>>(&mut self, ctx: &mut EventCtx, s: T) {
match &mut self.content {
FooterContent::Instruction(i) => *i = s.into(),
FooterContent::InstructionDescription(i, _d) => *i = s.into(),
FooterContent::PageCounter(page_counter) => page_counter.instruction = s.into(),
_ => {
#[cfg(feature = "ui_debug")]
panic!("not supported")
}
}
ctx.request_paint();
}
pub fn update_description<T: Into<TString<'a>>>(&mut self, ctx: &mut EventCtx, s: T) {
if let Some(ref mut content) = self.content {
if let Some(text) = content.as_description_mut() {
*text = s.into();
ctx.request_paint();
} else {
#[cfg(feature = "ui_debug")]
panic!("footer does not have description")
}
if let FooterContent::InstructionDescription(_i, d) = &mut self.content {
*d = s.into();
ctx.request_paint();
} else {
#[cfg(feature = "ui_debug")]
panic!("footer does not have description")
}
}
pub fn update_page_counter(&mut self, ctx: &mut EventCtx, n: u8) {
if let Some(ref mut content) = self.content {
if let Some(counter) = content.as_page_counter_mut() {
counter.update_current_page(n);
pub fn update_page_counter(&mut self, ctx: &mut EventCtx, current: usize, max: Option<usize>) {
match &mut self.content {
FooterContent::PageCounter(counter) => {
counter.update_current_page(current);
self.swipe_allow_down = counter.is_first_page();
self.swipe_allow_up = counter.is_last_page();
ctx.request_paint();
} else {
}
FooterContent::PageHint(page_hint) => {
page_hint.update_current_page(current, max);
self.swipe_allow_down = page_hint.is_first_page();
self.swipe_allow_up = page_hint.is_last_page();
ctx.request_paint();
}
_ => {
#[cfg(feature = "ui_debug")]
panic!("footer does not have counter")
}
@ -99,11 +137,7 @@ impl<'a> Footer<'a> {
}
pub fn height(&self) -> i16 {
if self.content.is_some() {
Footer::HEIGHT_DEFAULT
} else {
Footer::HEIGHT_SIMPLE
}
self.content.height()
}
pub fn with_swipe(self, swipe_direction: SwipeDirection) -> Self {
@ -125,15 +159,7 @@ impl<'a> Component for Footer<'a> {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
let h = bounds.height();
assert!(h == Footer::HEIGHT_SIMPLE || h == Footer::HEIGHT_DEFAULT);
if let Some(content) = &mut self.content {
if let Some(counter) = content.as_page_counter_mut() {
if h == Footer::HEIGHT_DEFAULT {
counter.place(bounds.split_top(Footer::HEIGHT_SIMPLE).0);
}
}
}
assert!(bounds.height() == self.content.height());
self.area = bounds;
self.area
}
@ -189,49 +215,7 @@ impl<'a> Component for Footer<'a> {
};
target.with_origin(offset, &|target| {
// show description/counter only if there is space for it
if self.area.height() == Footer::HEIGHT_DEFAULT {
if let Some(content) = &self.content {
match content {
FooterContent::Description(text) => {
let area_description = self.area.split_top(Footer::HEIGHT_SIMPLE).0;
let text_description_font_descent = Footer::STYLE_DESCRIPTION
.text_font
.visible_text_height_ex("Ay")
.1;
let text_description_baseline = area_description.bottom_center()
- Offset::y(text_description_font_descent);
text.map(|t| {
Text::new(text_description_baseline, t)
.with_font(Footer::STYLE_DESCRIPTION.text_font)
.with_fg(Footer::STYLE_DESCRIPTION.text_color)
.with_align(Alignment::Center)
.render(target);
});
}
FooterContent::PageCounter(counter) => {
counter.render(target);
}
}
}
}
let area_instruction = self.area.split_bottom(Footer::HEIGHT_SIMPLE).1;
let text_instruction_font_descent = Footer::STYLE_INSTRUCTION
.text_font
.visible_text_height_ex("Ay")
.1;
let text_instruction_baseline =
area_instruction.bottom_center() - Offset::y(text_instruction_font_descent);
self.instruction.map(|t| {
Text::new(text_instruction_baseline, t)
.with_font(Footer::STYLE_INSTRUCTION.text_font)
.with_fg(Footer::STYLE_INSTRUCTION.text_color)
.with_align(Alignment::Center)
.render(target);
});
self.content.render(self.area, target);
shape::Bar::new(self.area)
.with_alpha(mask)
.with_fg(Color::black())
@ -245,89 +229,138 @@ impl<'a> Component for Footer<'a> {
impl crate::trace::Trace for Footer<'_> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Footer");
if let Some(content) = &self.content {
match content {
FooterContent::Description(text) => t.string("description", *text),
FooterContent::PageCounter(counter) => counter.trace(t),
match &self.content {
FooterContent::Instruction(i) => {
t.string("instruction", *i);
}
FooterContent::InstructionDescription(i, d) => {
t.string("description", *d);
t.string("instruction", *i);
}
FooterContent::PageCounter(counter) => counter.trace(t),
FooterContent::PageHint(page_hint) => {
t.string("description", page_hint.description());
t.string("instruction", page_hint.instruction());
}
}
t.string("instruction", self.instruction);
}
}
impl<'a> FooterContent<'a> {
pub fn as_description_mut(&mut self) -> Option<&mut TString<'a>> {
match self {
FooterContent::Description(ref mut text) => Some(text),
_ => None,
fn height(&self) -> i16 {
if matches!(self, FooterContent::Instruction(_)) {
Footer::HEIGHT_SIMPLE
} else {
Footer::HEIGHT_DEFAULT
}
}
pub fn as_page_counter_mut(&mut self) -> Option<&mut PageCounter> {
fn render<'s>(&'s self, area: Rect, target: &mut impl Renderer<'s>)
where
's: 'a,
{
match self {
FooterContent::PageCounter(ref mut counter) => Some(counter),
_ => None,
FooterContent::Instruction(instruction) => {
Self::render_instruction(target, area, instruction);
}
FooterContent::InstructionDescription(instruction, description) => {
Self::render_description(target, area, description);
Self::render_instruction(target, area, instruction);
}
FooterContent::PageCounter(page_counter) => page_counter.render(target, area),
FooterContent::PageHint(page_hint) => {
Self::render_description(target, area, &page_hint.description());
Self::render_instruction(target, area, &page_hint.instruction());
}
}
}
fn render_description<'s>(
target: &mut impl Renderer<'s>,
area: Rect,
description: &TString<'a>,
) {
let area_description = area.split_top(Footer::HEIGHT_SIMPLE).0;
let text_description_font_descent = Footer::STYLE_DESCRIPTION
.text_font
.visible_text_height_ex("Ay")
.1;
let text_description_baseline =
area_description.bottom_center() - Offset::y(text_description_font_descent);
description.map(|t| {
Text::new(text_description_baseline, t)
.with_font(Footer::STYLE_DESCRIPTION.text_font)
.with_fg(Footer::STYLE_DESCRIPTION.text_color)
.with_align(Alignment::Center)
.render(target)
});
}
fn render_instruction<'s>(
target: &mut impl Renderer<'s>,
area: Rect,
instruction: &TString<'a>,
) {
let area_instruction = area.split_bottom(Footer::HEIGHT_SIMPLE).1;
let text_instruction_font_descent = Footer::STYLE_INSTRUCTION
.text_font
.visible_text_height_ex("Ay")
.1;
let text_instruction_baseline =
area_instruction.bottom_center() - Offset::y(text_instruction_font_descent);
instruction.map(|t| {
Text::new(text_instruction_baseline, t)
.with_font(Footer::STYLE_INSTRUCTION.text_font)
.with_fg(Footer::STYLE_INSTRUCTION.text_color)
.with_align(Alignment::Center)
.render(target)
});
}
}
/// Helper component used within Footer instead of description for page count
/// indication, rendered e.g. as: '1 / 20'.
#[derive(Clone)]
struct PageCounter {
area: Rect,
pub instruction: TString<'static>,
font: Font,
page_curr: u8,
page_max: u8,
}
impl PageCounter {
fn new(page_max: u8) -> Self {
fn new(page_max: u8, instruction: TString<'static>) -> Self {
Self {
area: Rect::zero(),
page_curr: 1,
instruction,
page_curr: 0,
page_max,
font: Font::SUB,
}
}
fn update_current_page(&mut self, new_value: u8) {
self.page_curr = new_value.clamp(1, self.page_max);
fn update_current_page(&mut self, new_value: usize) {
self.page_curr = (new_value as u8).clamp(0, self.page_max.saturating_sub(1));
}
fn is_first_page(&self) -> bool {
self.page_curr == 1
self.page_curr == 0
}
fn is_last_page(&self) -> bool {
self.page_curr == self.page_max
self.page_curr + 1 == self.page_max
}
}
impl Component for PageCounter {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
self.area
}
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
None
}
fn paint(&mut self) {
todo!("remove when ui-t3t1 done")
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let color = if self.page_curr == self.page_max {
impl PageCounter {
fn render<'s>(&'s self, target: &mut impl Renderer<'s>, area: Rect) {
let color = if self.is_last_page() {
theme::GREEN_LIGHT
} else {
theme::GREY_LIGHT
};
let string_curr = uformat!("{}", self.page_curr);
let string_curr = uformat!("{}", self.page_curr + 1);
let string_max = uformat!("{}", self.page_max);
// center the whole counter "x / yz"
@ -337,8 +370,9 @@ impl Component for PageCounter {
let width_num_max = self.font.text_width(&string_max);
let width_total = width_num_curr + width_foreslash + width_num_max + 2 * offset_x.x;
let center_x = self.area.center().x;
let counter_y = self.font.vert_center(self.area.y0, self.area.y1, "0");
let counter_area = area.split_top(Footer::HEIGHT_SIMPLE).0;
let center_x = counter_area.center().x;
let counter_y = self.font.vert_center(counter_area.y0, counter_area.y1, "0");
let counter_start_x = center_x - width_total / 2;
let counter_end_x = center_x + width_total / 2;
let base_num_curr = Point::new(counter_start_x, counter_y);
@ -359,6 +393,8 @@ impl Component for PageCounter {
.with_fg(color)
.with_font(self.font)
.render(target);
FooterContent::render_instruction(target, area, &self.instruction);
}
}
@ -370,3 +406,58 @@ impl crate::trace::Trace for PageCounter {
t.int("page max", self.page_max.into());
}
}
#[derive(Clone)]
struct PageHint {
pub description: TString<'static>,
pub description_last: TString<'static>,
pub instruction: TString<'static>,
pub instruction_last: TString<'static>,
pub page_curr: u8,
pub page_num: u8,
}
impl PageHint {
fn update_current_page(&mut self, current: usize, max: Option<usize>) {
self.page_curr = (current as u8).clamp(0, self.page_num.saturating_sub(1));
if let Some(max) = max {
self.page_num = max as u8;
}
}
fn update_max_page(&mut self, max: usize) {
self.page_num = max as u8;
}
fn is_single_page(&self) -> bool {
self.page_num <= 1
}
fn is_first_page(&self) -> bool {
self.page_curr == 0
}
fn is_last_page(&self) -> bool {
self.page_curr + 1 == self.page_num
}
fn description(&self) -> TString<'static> {
if self.is_single_page() {
TString::empty()
} else if self.is_last_page() {
self.description_last
} else {
self.description
}
}
fn instruction(&self) -> TString<'static> {
if self.is_single_page() {
TString::empty()
} else if self.is_last_page() {
self.instruction_last
} else {
self.instruction
}
}
}

View File

@ -196,7 +196,24 @@ where
#[inline(never)]
pub fn with_footer_counter(mut self, instruction: TString<'static>, max_value: u8) -> Self {
self.footer = Some(Footer::new(instruction, None).with_page_counter(max_value));
self.footer = Some(Footer::with_page_counter(max_value, instruction));
self
}
#[inline(never)]
pub fn with_footer_page_hint(
mut self,
description: TString<'static>,
description_last: TString<'static>,
instruction: TString<'static>,
instruction_last: TString<'static>,
) -> Self {
self.footer = Some(Footer::with_page_hint(
description,
description_last,
instruction,
instruction_last,
));
self
}
@ -231,9 +248,14 @@ where
res
}
pub fn update_footer_counter(&mut self, ctx: &mut EventCtx, new_val: u8) {
pub fn update_footer_counter(
&mut self,
ctx: &mut EventCtx,
current: usize,
max: Option<usize>,
) {
if let Some(footer) = &mut self.footer {
footer.update_page_counter(ctx, new_val);
footer.update_page_counter(ctx, current, max);
}
}

View File

@ -4,6 +4,8 @@ mod address_details;
mod binary_selection;
pub mod bl_confirm;
mod button;
#[cfg(feature = "universal_fw")]
mod choose_credential;
#[cfg(feature = "translations")]
mod coinjoin_progress;
mod fido;
@ -46,6 +48,8 @@ pub use address_details::AddressDetails;
#[cfg(feature = "ui_overlay")]
pub use binary_selection::{BinarySelection, BinarySelectionMsg};
pub use button::{Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, IconText};
#[cfg(feature = "universal_fw")]
pub use choose_credential::ChooseCredential;
#[cfg(feature = "translations")]
pub use coinjoin_progress::CoinJoinProgress;
pub use error::ErrorScreen;

View File

@ -84,9 +84,9 @@ impl<'a> Component for ShareWords<'a> {
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let page_index = self.frame.inner().inner().page_index as u8;
let page_index = self.frame.inner().inner().page_index;
if let Some(repeated_indices) = &self.repeated_indices {
if repeated_indices.contains(&page_index) {
if repeated_indices.contains(&(page_index as u8)) {
let updated_subtitle = TString::from_translation(TR::reset__the_word_is_repeated);
self.frame
.update_subtitle(ctx, updated_subtitle, Some(theme::TEXT_SUB_GREEN_LIME));
@ -95,7 +95,8 @@ impl<'a> Component for ShareWords<'a> {
.update_subtitle(ctx, self.subtitle, Some(theme::TEXT_SUB_GREY));
}
}
self.frame.update_footer_counter(ctx, page_index + 1);
self.frame
.update_footer_counter(ctx, page_index as usize, None);
self.frame.event(ctx, event)
}

View File

@ -7,8 +7,7 @@ use crate::{
event::SwipeEvent,
geometry::{Offset, Rect},
lerp::Lerp,
shape,
shape::Renderer,
shape::{self, Renderer},
util::animation_disabled,
},
};

View File

@ -1,6 +1,6 @@
use heapless::Vec;
use super::theme;
use super::{theme, InternallySwipable};
use crate::{
strutil::TString,
time::{Duration, Stopwatch},
@ -266,7 +266,7 @@ impl Component for VerticalMenu {
let opacities = [item1_opacity, item2_opacity, item3_opacity];
target.with_origin(offset, &|target| {
target.with_translate(offset, &|target| {
// render buttons separated by thin bars
for (i, button) in (&self.buttons).into_iter().take(self.n_items).enumerate() {
button.render(target);

View File

@ -11,16 +11,16 @@ use crate::{
},
flow::{
base::{DecisionBuilder as _, StateChange},
FlowMsg, FlowState, SwipeFlow, SwipePage,
FlowMsg, FlowState, SwipeFlow,
},
layout::obj::LayoutObj,
model_mercury::component::{FidoCredential, SwipeContent},
},
};
use super::super::{
component::{
Frame, FrameMsg, PagedVerticalMenu, PromptScreen, VerticalMenu, VerticalMenuChoiceMsg,
ChooseCredential, FidoCredential, Frame, FrameMsg, PromptMsg, PromptScreen, SwipeContent,
VerticalMenu, VerticalMenuChoiceMsg,
},
theme,
};
@ -49,6 +49,7 @@ impl FlowState for ConfirmFido {
match (self, direction) {
(Self::Intro, SwipeDirection::Left) => Self::Menu.swipe(direction),
(Self::Intro, SwipeDirection::Up) => Self::ChooseCredential.swipe(direction),
(Self::ChooseCredential, SwipeDirection::Down) => Self::Intro.swipe(direction),
(Self::Details, SwipeDirection::Up) => Self::Tap.swipe(direction),
(Self::Tap, SwipeDirection::Down) => Self::Details.swipe(direction),
_ => self.do_nothing(),
@ -122,26 +123,11 @@ impl ConfirmFido {
.try_into()
.unwrap_or_else(|_| TString::from_str("-"))
};
let content_choose_credential = Frame::left_aligned(
TR::fido__title_select_credential.into(),
SwipeContent::new(SwipePage::vertical(PagedVerticalMenu::new(
num_accounts,
label_fn,
))),
)
.with_subtitle(TR::fido__title_for_authentication.into())
.with_menu_button()
.with_footer(
TR::instructions__swipe_up.into(),
(num_accounts > 2).then_some(TR::fido__more_credentials.into()),
)
.with_swipe(SwipeDirection::Down, SwipeSettings::default())
.with_swipe(SwipeDirection::Right, SwipeSettings::immediate())
.with_vertical_pages()
.map(|msg| match msg {
FrameMsg::Button(_) => Some(FlowMsg::Info),
FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)),
});
let content_choose_credential =
ChooseCredential::new(label_fn, num_accounts).map(|msg| match msg {
FrameMsg::Button(_) => Some(FlowMsg::Info),
FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)),
});
let get_account = move || {
let current = CRED_SELECTED.load(Ordering::Relaxed);

View File

@ -51,6 +51,13 @@ pub trait Renderer<'a> {
inner(self);
self.set_viewport(original);
}
fn with_translate(&mut self, offset: Offset, inner: &dyn Fn(&mut Self)) {
let original = self.viewport();
self.set_viewport(self.viewport().translate(offset));
inner(self);
self.set_viewport(original);
}
}
// ==========================================================================

View File

@ -18,6 +18,7 @@ async def list_resident_credentials(
TR.fido__title_list_credentials,
description=TR.fido__export_credentials,
verb=TR.buttons__export,
prompt_screen=True,
)
creds = [
WebAuthnCredential(