feat(leaderboard): add custom public display name
Adds `leaderboard_display_name: Option<String>` to `Settings` (serde default = None, backwards-compatible). When set, this name is submitted to the server on opt-in instead of the player's username, giving players a separate public identity on the leaderboard. Engine changes: - `handle_opt_in_button` prefers `leaderboard_display_name` over username - Leaderboard panel shows "Public name: X" row with "Set Name" button - "Set Name" opens a modal with a single text-input field (32-char max) - Save/Cancel buttons write to SettingsResource and persist to disk Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -231,6 +231,12 @@ pub struct Settings {
|
|||||||
/// cleanly to `None` via `#[serde(default)]`.
|
/// cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_difficulty: Option<DifficultyLevel>,
|
pub last_difficulty: Option<DifficultyLevel>,
|
||||||
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
|
/// before submission. Older `settings.json` files written before this
|
||||||
|
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub leaderboard_display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -350,6 +356,7 @@ impl Default for Settings {
|
|||||||
disable_smart_default_size: false,
|
disable_smart_default_size: false,
|
||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
|
leaderboard_display_name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,23 +9,24 @@
|
|||||||
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
||||||
//! the panel shows "Not available" immediately.
|
//! the panel shows "Not available" immediately.
|
||||||
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::{ButtonState, keyboard::KeyboardInput, mouse::{MouseScrollUnit, MouseWheel}};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
use solitaire_data::settings::SyncBackend;
|
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
||||||
use solitaire_sync::LeaderboardEntry;
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
ScrimDismissible,
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
|
||||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
|
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
|
||||||
|
VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, Z_PAUSE_DIALOG,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -96,6 +97,30 @@ struct OptInTask(Option<Task<Result<(), String>>>);
|
|||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct OptOutTask(Option<Task<Result<(), String>>>);
|
struct OptOutTask(Option<Task<Result<(), String>>>);
|
||||||
|
|
||||||
|
/// Marker on the "Set Name" button inside the leaderboard panel.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SetDisplayNameButton;
|
||||||
|
|
||||||
|
/// Marker on the display-name editor modal root.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DisplayNameModal;
|
||||||
|
|
||||||
|
/// Text currently typed in the display-name modal's input field.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct DisplayNameBuffer(String);
|
||||||
|
|
||||||
|
/// Marker on the text node inside the display-name input field.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DisplayNameTextField;
|
||||||
|
|
||||||
|
/// Marker on the "Save" button in the display-name modal.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DisplayNameConfirmButton;
|
||||||
|
|
||||||
|
/// Marker on the "Cancel" button in the display-name modal.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DisplayNameCancelButton;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -111,12 +136,13 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
.init_resource::<ClosedThisFrame>()
|
.init_resource::<ClosedThisFrame>()
|
||||||
.init_resource::<OptInTask>()
|
.init_resource::<OptInTask>()
|
||||||
.init_resource::<OptOutTask>()
|
.init_resource::<OptOutTask>()
|
||||||
|
.init_resource::<DisplayNameBuffer>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||||
// `DefaultPlugins`; register it explicitly so the
|
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||||
// leaderboard-scroll system also runs cleanly under
|
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||||
// `MinimalPlugins` in tests.
|
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
|
.add_message::<KeyboardInput>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -129,6 +155,10 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
poll_opt_in_task,
|
poll_opt_in_task,
|
||||||
handle_opt_out_button,
|
handle_opt_out_button,
|
||||||
poll_opt_out_task,
|
poll_opt_out_task,
|
||||||
|
handle_set_display_name_button,
|
||||||
|
handle_display_name_text_input,
|
||||||
|
handle_display_name_confirm,
|
||||||
|
handle_display_name_cancel,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
)
|
)
|
||||||
@@ -156,6 +186,7 @@ fn toggle_leaderboard_screen(
|
|||||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
data: Res<LeaderboardResource>,
|
data: Res<LeaderboardResource>,
|
||||||
provider: Option<Res<SyncProviderResource>>,
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
mut task_res: ResMut<LeaderboardFetchTask>,
|
mut task_res: ResMut<LeaderboardFetchTask>,
|
||||||
mut closed_flag: ResMut<ClosedThisFrame>,
|
mut closed_flag: ResMut<ClosedThisFrame>,
|
||||||
@@ -174,7 +205,8 @@ fn toggle_leaderboard_screen(
|
|||||||
let remote_available = provider
|
let remote_available = provider
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|p| p.0.backend_name() != "local");
|
.is_some_and(|p| p.0.backend_name() != "local");
|
||||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
|
||||||
|
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
|
||||||
|
|
||||||
// Start a background fetch if not already in flight.
|
// Start a background fetch if not already in flight.
|
||||||
if task_res.0.is_none()
|
if task_res.0.is_none()
|
||||||
@@ -201,12 +233,14 @@ fn poll_leaderboard_fetch(
|
|||||||
/// When a fetch completes, cache the data and update any open panel.
|
/// When a fetch completes, cache the data and update any open panel.
|
||||||
/// Skips the panel rebuild if the user closed the panel in this same frame
|
/// Skips the panel rebuild if the user closed the panel in this same frame
|
||||||
/// (commands are deferred, so the query would still see the despawned entity).
|
/// (commands are deferred, so the query would still see the despawned entity).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn update_leaderboard_panel(
|
fn update_leaderboard_panel(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut result_res: ResMut<LeaderboardFetchResult>,
|
mut result_res: ResMut<LeaderboardFetchResult>,
|
||||||
mut data: ResMut<LeaderboardResource>,
|
mut data: ResMut<LeaderboardResource>,
|
||||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
provider: Option<Res<SyncProviderResource>>,
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
closed_flag: Res<ClosedThisFrame>,
|
closed_flag: Res<ClosedThisFrame>,
|
||||||
) {
|
) {
|
||||||
@@ -235,9 +269,10 @@ fn update_leaderboard_panel(
|
|||||||
let remote_available = provider
|
let remote_available = provider
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|p| p.0.backend_name() != "local");
|
.is_some_and(|p| p.0.backend_name() != "local");
|
||||||
|
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,12 +340,18 @@ fn handle_opt_in_button(
|
|||||||
let display_name = settings
|
let display_name = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|s| {
|
.and_then(|s| {
|
||||||
|
// Prefer an explicit display name; fall back to server username.
|
||||||
|
s.0.leaderboard_display_name
|
||||||
|
.as_deref()
|
||||||
|
.or_else(|| {
|
||||||
if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend {
|
if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend {
|
||||||
Some(username.clone())
|
Some(username.as_str())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
.unwrap_or_else(|| "Player".to_string());
|
.unwrap_or_else(|| "Player".to_string());
|
||||||
|
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
@@ -391,6 +432,7 @@ fn spawn_leaderboard_screen(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
data: &LeaderboardResource,
|
data: &LeaderboardResource,
|
||||||
remote_available: bool,
|
remote_available: bool,
|
||||||
|
effective_display_name: Option<&str>,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||||
@@ -426,6 +468,33 @@ fn spawn_leaderboard_screen(
|
|||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Public name row: shows the effective display name + "Set Name" button.
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
let label = match effective_display_name {
|
||||||
|
Some(n) => format!("Public name: {n}"),
|
||||||
|
None => "Public name: (same as username)".to_string(),
|
||||||
|
};
|
||||||
|
row.spawn((
|
||||||
|
Text::new(label),
|
||||||
|
font_caption.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
SetDisplayNameButton,
|
||||||
|
"Set Name",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Tertiary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Opt In / Opt Out row uses the same modal-button helpers as
|
// Opt In / Opt Out row uses the same modal-button helpers as
|
||||||
// the rest of the UI for consistent hover / press feedback.
|
// the rest of the UI for consistent hover / press feedback.
|
||||||
spawn_modal_actions(card, |row| {
|
spawn_modal_actions(card, |row| {
|
||||||
@@ -606,6 +675,194 @@ fn data_cell(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Display-name editor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Opens the display-name editor modal when the "Set Name" button is pressed.
|
||||||
|
fn handle_set_display_name_button(
|
||||||
|
button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>,
|
||||||
|
existing: Query<(), With<DisplayNameModal>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut buf: ResMut<DisplayNameBuffer>,
|
||||||
|
) {
|
||||||
|
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return; // already open
|
||||||
|
}
|
||||||
|
buf.0 = settings
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.0.leaderboard_display_name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
spawn_display_name_modal(&mut commands, &buf.0, font_res.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes keyboard input into the display-name buffer while the editor is open.
|
||||||
|
fn handle_display_name_text_input(
|
||||||
|
screen: Query<(), With<DisplayNameModal>>,
|
||||||
|
mut key_events: MessageReader<KeyboardInput>,
|
||||||
|
mut buf: ResMut<DisplayNameBuffer>,
|
||||||
|
mut text_q: Query<&mut Text, With<DisplayNameTextField>>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
key_events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for ev in key_events.read() {
|
||||||
|
if ev.state != ButtonState::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ev.key_code == KeyCode::Backspace {
|
||||||
|
buf.0.pop();
|
||||||
|
} else if let Some(ch) = ev.text.as_deref().and_then(printable_char_dn)
|
||||||
|
&& buf.0.len() < 32
|
||||||
|
{
|
||||||
|
buf.0.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for mut text in &mut text_q {
|
||||||
|
text.0 = if buf.0.is_empty() {
|
||||||
|
" ".to_string()
|
||||||
|
} else {
|
||||||
|
buf.0.clone()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the typed display name to `SettingsResource` and closes the modal.
|
||||||
|
fn handle_display_name_confirm(
|
||||||
|
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
||||||
|
screens: Query<Entity, With<DisplayNameModal>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
buf: Res<DisplayNameBuffer>,
|
||||||
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
) {
|
||||||
|
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(mut settings) = settings {
|
||||||
|
let trimmed = buf.0.trim().to_string();
|
||||||
|
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
};
|
||||||
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discards any typed text and closes the display-name editor modal.
|
||||||
|
fn handle_display_name_cancel(
|
||||||
|
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
|
||||||
|
screens: Query<Entity, With<DisplayNameModal>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_display_name_modal(
|
||||||
|
commands: &mut Commands,
|
||||||
|
current_name: &str,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let make_font = |size: f32| TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: size,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
spawn_modal(commands, DisplayNameModal, Z_PAUSE_DIALOG, |card| {
|
||||||
|
spawn_modal_header(card, "Public Display Name", font_res);
|
||||||
|
|
||||||
|
card.spawn((
|
||||||
|
Text::new(
|
||||||
|
"Shown on the leaderboard when you opt in. Leave blank to use your username.",
|
||||||
|
),
|
||||||
|
make_font(TYPE_CAPTION),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Input field container.
|
||||||
|
card.spawn((
|
||||||
|
Node {
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||||
|
min_height: Val::Px(32.0),
|
||||||
|
min_width: Val::Px(260.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
BorderColor::all(ACCENT_PRIMARY),
|
||||||
|
))
|
||||||
|
.with_children(|border| {
|
||||||
|
let initial = if current_name.is_empty() {
|
||||||
|
" ".to_string()
|
||||||
|
} else {
|
||||||
|
current_name.to_string()
|
||||||
|
};
|
||||||
|
border.spawn((
|
||||||
|
DisplayNameTextField,
|
||||||
|
Text::new(initial),
|
||||||
|
make_font(TYPE_BODY),
|
||||||
|
TextColor(if current_name.is_empty() {
|
||||||
|
TEXT_DISABLED
|
||||||
|
} else {
|
||||||
|
TEXT_PRIMARY
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Max 32 characters."),
|
||||||
|
make_font(TYPE_CAPTION),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
DisplayNameCancelButton,
|
||||||
|
"Cancel",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Tertiary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
DisplayNameConfirmButton,
|
||||||
|
"Save",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
||||||
|
fn printable_char_dn(text: &str) -> Option<char> {
|
||||||
|
let ch = text.chars().next()?;
|
||||||
|
(' '..='~').contains(&ch).then_some(ch)
|
||||||
|
}
|
||||||
|
|
||||||
fn format_secs(secs: u64) -> String {
|
fn format_secs(secs: u64) -> String {
|
||||||
let m = secs / 60;
|
let m = secs / 60;
|
||||||
let s = secs % 60;
|
let s = secs % 60;
|
||||||
|
|||||||
Reference in New Issue
Block a user