From 8426852d7c72089b4aa49bb52e863c680279c0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Hlusi=C4=8Dka?= Date: Tue, 27 Jan 2026 02:46:53 +0100 Subject: [PATCH] More GUI progress --- firmware/Cargo.lock | 11 + firmware/acid-firmware/Cargo.toml | 1 + firmware/acid-firmware/src/main.rs | 3 +- firmware/acid-firmware/src/ui/mod.rs | 372 ++++++++++++++++-- firmware/acid-firmware/ui/login-view.slint | 9 +- firmware/acid-firmware/ui/main.slint | 92 +++-- .../acid-firmware/ui/user-edit-view.slint | 64 +++ firmware/acid-firmware/ui/users-view.slint | 48 ++- 8 files changed, 504 insertions(+), 96 deletions(-) create mode 100644 firmware/acid-firmware/ui/user-edit-view.slint diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index bc1d6b7..39b2af8 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -53,6 +53,7 @@ dependencies = [ "rmk", "rtt-target", "serde", + "serde_bytes", "sha2", "slint", "slint-build", @@ -6499,6 +6500,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/firmware/acid-firmware/Cargo.toml b/firmware/acid-firmware/Cargo.toml index 6d155bb..17ca028 100644 --- a/firmware/acid-firmware/Cargo.toml +++ b/firmware/acid-firmware/Cargo.toml @@ -84,6 +84,7 @@ embedded-storage-async = "0.4.1" postcard = { version = "1.1", default-features = false, features = ["alloc", "postcard-derive"] } # TODO: defmt serde = { version = "1.0", default-features = false, features = ["derive"] } # serde_with = { version = "3.16", default-features = false, features = ["alloc", "macros"] } +serde_bytes = { version = "0.11.19", default-features = false, features = ["alloc"] } chrono = { version = "0.4.43", default-features = false, features = ["alloc", "serde"] } # TODO: defmt # Crates for serial UART CLI diff --git a/firmware/acid-firmware/src/main.rs b/firmware/acid-firmware/src/main.rs index abfe14b..cba4b81 100644 --- a/firmware/acid-firmware/src/main.rs +++ b/firmware/acid-firmware/src/main.rs @@ -97,6 +97,7 @@ esp_bootloader_esp_idf::esp_app_desc!(); // Memory allocation regions. // These can be debugged using `xtensa-esp32s3-elf-size -A `. +// A panic such as `memory allocation of 3740121773 bytes failed` is caused by a heap overflow. The size is `DEEDBAAD` in hex. /// Total heap size const HEAP_SIZE: usize = 128 * 1024; @@ -158,7 +159,7 @@ async fn main(_spawner: Spawner) { // Use the internal DRAM as the heap. // Memory reclaimed from the esp-idf bootloader. - const HEAP_SIZE_RECLAIMED: usize = 64 * 1024; + const HEAP_SIZE_RECLAIMED: usize = 72 * 1024; esp_alloc::heap_allocator!(#[ram(reclaimed)] size: HEAP_SIZE_RECLAIMED); esp_alloc::heap_allocator!(size: HEAP_SIZE - HEAP_SIZE_RECLAIMED); info!("Heap initialized! {:#?}", esp_alloc::HEAP.stats()); diff --git a/firmware/acid-firmware/src/ui/mod.rs b/firmware/acid-firmware/src/ui/mod.rs index bed30d0..902ad10 100644 --- a/firmware/acid-firmware/src/ui/mod.rs +++ b/firmware/acid-firmware/src/ui/mod.rs @@ -1,6 +1,8 @@ // #![cfg_attr(not(feature = "simulator"), no_main)] use core::{ + cell::RefCell, + ffi::CStr, iter::Chain, ops::{Deref, DerefMut, Range}, }; @@ -9,6 +11,7 @@ use alloc::{ borrow::Cow, boxed::Box, ffi::CString, + format, rc::Rc, string::{String, ToString}, vec, @@ -24,11 +27,14 @@ use esp_hal::rng::Trng; use esp_storage::FlashStorage; use itertools::Itertools; use log::{info, warn}; +use password_hash::Key; use rmk::futures::TryFutureExt; use serde::{Deserialize, Serialize}; +use serde_bytes::Bytes; use slint::{ModelRc, SharedString, StandardListViewItem, VecModel}; use spectre_api_sys::{ - SpectreAlgorithm, SpectreCounter, SpectreKeyPurpose, SpectreResultType, SpectreUserKey, + SpectreAlgorithm, SpectreCounter, SpectreKeyID, SpectreKeyPurpose, SpectreResultType, + SpectreUserKey, }; #[cfg(feature = "limit-fps")] @@ -39,6 +45,7 @@ use crate::{ AcidDatabase, DbKey, DbPathSpectreUserSite, DbPathSpectreUserSites, DbPathSpectreUsers, PartitionAcid, ReadTransactionExt, }, + ffi::alloc::__spre_free, ui::backend::SlintBackend, util::DurationExt, }; @@ -48,7 +55,7 @@ pub mod window_adapter; slint::include_modules!(); -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +#[derive(Deserialize, Serialize, Default, Clone, Debug, PartialEq, Eq)] struct SpectreUsersConfig { users: Vec, } @@ -56,6 +63,10 @@ struct SpectreUsersConfig { #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] struct SpectreUserConfig { username: String, + #[serde(with = "serde_bytes")] + encrypted_key: Key, + #[serde(with = "serde_bytes")] + key_id: [u8; 32], } trait ReprConvert: Copy { @@ -149,6 +160,29 @@ struct SpectreSite { config: SpectreSiteConfig, } +fn spectre_derive_user_key(username: &CStr, password: &CStr) -> SpectreUserKey { + let user_key_start = Instant::now(); + + unsafe { + let user_key = &*spectre_api_sys::spectre_user_key( + username.as_ptr(), + password.as_ptr(), + SpectreAlgorithm::Current, + ); + let user_key_duration = Instant::now().duration_since(user_key_start); + warn!( + "User key derived in {} seconds:\n{user_key:02x?}", + user_key_duration.display_as_secs() + ); + let user_key_stack = user_key.clone(); + + // TODO: Erase memory before freeing + __spre_free(user_key as *const _ as *mut _); + + user_key_stack + } +} + #[embassy_executor::task] pub async fn run_renderer_task(backend: SlintBackend, flash_part_acid: PartitionAcid) { let db = AcidDatabase::mount(flash_part_acid).await; @@ -156,6 +190,8 @@ pub async fn run_renderer_task(backend: SlintBackend, flash_part_acid: Partition let value = SpectreUsersConfig { users: vec![SpectreUserConfig { username: "test".to_string(), + encrypted_key: [0; _], + key_id: [0; _], }], }; @@ -242,13 +278,303 @@ pub async fn run_renderer_task(backend: SlintBackend, flash_part_acid: Partition slint::platform::set_platform(Box::new(backend)).expect("backend already initialized"); + struct State { + window: AppWindow, + db: AcidDatabase, + users: SpectreUsersConfig, + /// Currently active view. + view: AppState, + // Retained state for each view. + state_login: StateLogin, + state_users: StateUsers, + state_user_edit: StateUserEdit, + state_user_sites: Option, + } + + impl State { + fn process_callback_message(&mut self, message: CallbackMessage) { + match self.view { + AppState::Login => StateLogin::process_callback_message(self, message), + AppState::Users => StateUsers::process_callback_message(self, message), + AppState::UserEdit => StateUserEdit::process_callback_message(self, message), + AppState::UserSites => StateUserSites::process_callback_message(self, message), + } + } + + fn set_view(&mut self, view: AppState, reset: bool) { + self.view = view; + self.window.set_app_state(view); + + if reset { + match self.view { + AppState::Login => self.state_login = Default::default(), + AppState::Users => self.state_users = Default::default(), + AppState::UserEdit => self.state_user_edit = Default::default(), + AppState::UserSites => self.state_user_sites = Default::default(), + } + } + } + } + + trait AppViewTrait { + fn process_callback_message(state: &mut State, message: CallbackMessage) {} + } + + #[derive(Default)] + struct StateLogin {} + + impl AppViewTrait for StateLogin { + fn process_callback_message(state: &mut State, message: CallbackMessage) { + match message { + CallbackMessage::Login(CallbackMessageLogin::PwAccepted { username, password }) => { + let username_c = CString::new(&*username) + .expect("Username cannot be converted to a C string."); + let password_c = CString::new(&*password) + .expect("Password cannot be converted to a C string."); + let user_key = spectre_derive_user_key(&username_c, &password_c); + + // let site_key_start = Instant::now(); + // let site_key = &*spectre_api_sys::spectre_site_key( + // user_key as *const SpectreUserKey, + // c"example.org".as_ptr(), + // SpectreCounter::Initial, + // SpectreKeyPurpose::Authentication, + // c"".as_ptr(), + // ); + // let site_key_duration = Instant::now().duration_since(site_key_start); + // warn!( + // "Site key derived in {} seconds:\n{site_key:02x?}", + // site_key_duration.display_as_secs() + // ); + // TODO: Erase memory before freeing + // __spre_free(site_key as *const _ as *mut _); + let Some(user) = state + .users + .users + .iter() + .find(|user| &*user.username == &*username) + else { + return; + }; + + if user.key_id == user_key.keyID.bytes { + info!("Correct password entered for user {username:?}."); + state.state_user_sites = Some(StateUserSites { username, user_key }); + state.set_view(AppState::UserSites, true); + } else { + warn!("Incorrect password entered for user {username:?}."); + // TODO: Clear the input + } + } + _ => (), + } + } + } + + #[derive(Default)] + struct StateUsers {} + + impl AppViewTrait for StateUsers { + fn process_callback_message(state: &mut State, message: CallbackMessage) { + match message { + CallbackMessage::Escape => { + state.set_view(AppState::Login, false); + } + CallbackMessage::Users(CallbackMessageUsers::EditUser { username, new }) => { + state.state_user_edit = StateUserEdit { + username: username.clone(), + new, + hashed: None, + }; + state.window.set_user_edit_username(username); + state.set_view(AppState::UserEdit, false); + } + _ => (), + } + } + } + + #[derive(Default)] + struct StateUserEdit { + username: SharedString, + new: bool, + hashed: Option, + } + + impl AppViewTrait for StateUserEdit { + fn process_callback_message(state: &mut State, message: CallbackMessage) { + match message { + CallbackMessage::Escape => { + state.set_view(AppState::Users, false); + } + CallbackMessage::UserEdit(CallbackMessageUserEdit::ComputeIdenticon { + encrypted_key, + password, + }) => { + let username_c = CString::new(&*state.state_user_edit.username) + .expect("Username cannot be converted to a C string."); + let password_c = CString::new(&*password) + .expect("Password cannot be converted to a C string."); + // let user_key = spectre_derive_user_key(&username_c, &password_c); + let identicon: SharedString = unsafe { + let identicon = spectre_api_sys::spectre_identicon( + username_c.as_ptr(), + password_c.as_ptr(), + ); + // TODO: identicon.color + format!( + "{}{}{}{}", + CStr::from_ptr(identicon.leftArm).to_str().unwrap(), + CStr::from_ptr(identicon.body).to_str().unwrap(), + CStr::from_ptr(identicon.rightArm).to_str().unwrap(), + CStr::from_ptr(identicon.accessory).to_str().unwrap() + ) + .into() + }; + + state.window.set_user_edit_identicon(identicon.clone()); + state.state_user_edit.hashed = Some(ProposedPassword { + encrypted_key, + identicon, + }) + } + CallbackMessage::UserEdit(CallbackMessageUserEdit::Confirm { encrypted_key }) => { + // TODO + } + _ => (), + } + } + } + + struct ProposedPassword { + encrypted_key: SharedString, + identicon: SharedString, + } + + struct StateUserSites { + username: SharedString, + user_key: SpectreUserKey, + } + + impl AppViewTrait for StateUserSites {} + + enum CallbackMessage { + /// The escape key was pressed. + Escape, + Login(CallbackMessageLogin), + Users(CallbackMessageUsers), + UserEdit(CallbackMessageUserEdit), + UserSites(CallbackMessageUserSites), + } + + enum CallbackMessageLogin { + PwAccepted { + username: SharedString, + password: SharedString, + }, + } + + enum CallbackMessageUsers { + EditUser { username: SharedString, new: bool }, + } + + enum CallbackMessageUserEdit { + ComputeIdenticon { + encrypted_key: SharedString, + password: SharedString, + }, + Confirm { + encrypted_key: SharedString, + }, + } + + enum CallbackMessageUserSites {} + let main = AppWindow::new().unwrap(); + let state = Rc::new(RefCell::new(State { + window: main.clone_strong(), + users: { + let read = db.read_transaction().await; + let mut buffer = vec![0_u8; 128]; + match read + .read_to_vec(&DbKey::new(DbPathSpectreUsers), &mut buffer) + .await + { + Ok(bytes) => postcard::from_bytes::(&bytes).unwrap(), + Err(ekv::ReadError::KeyNotFound) => Default::default(), + Err(error) => panic!("Failed to read the users config: {error:?}"), + } + }, + db, + view: AppState::Login, + state_login: Default::default(), + state_users: Default::default(), + state_user_edit: Default::default(), + state_user_sites: Default::default(), + })); + + main.on_enter_view({ + let state = state.clone(); + move |view| { + state.borrow_mut().set_view(view, true); + } + }); + + main.on_escape({ + let state = state.clone(); + move || { + state + .borrow_mut() + .process_callback_message(CallbackMessage::Escape); + } + }); + main.on_login_pw_accepted({ - let main = main.clone_strong(); + let state = state.clone(); move |username, password| { - info!("username = {username:?}, password = {password:?}"); - main.set_app_state(AppState::UserSites); + state + .borrow_mut() + .process_callback_message(CallbackMessage::Login( + CallbackMessageLogin::PwAccepted { username, password }, + )); + } + }); + + main.on_users_edit_user({ + let state = state.clone(); + move |username, new| { + state + .borrow_mut() + .process_callback_message(CallbackMessage::Users(CallbackMessageUsers::EditUser { + username, + new, + })); + } + }); + + main.on_user_edit_compute_identicon({ + let state = state.clone(); + move |encrypted_key, password| { + state + .borrow_mut() + .process_callback_message(CallbackMessage::UserEdit( + CallbackMessageUserEdit::ComputeIdenticon { + encrypted_key, + password, + }, + )); + } + }); + + main.on_user_edit_confirm({ + let state = state.clone(); + move |encrypted_key| { + state + .borrow_mut() + .process_callback_message(CallbackMessage::UserEdit( + CallbackMessageUserEdit::Confirm { encrypted_key }, + )); } }); @@ -262,42 +588,6 @@ pub async fn run_renderer_task(backend: SlintBackend, flash_part_acid: Partition // }, // ))); - main.on_site_pw_accepted(|string| { - warn!("Accepted: {string}"); - let Ok(c_string) = CString::new(&*string) else { - warn!("String cannot be converted to a C string: {string:?}"); - return; - }; - unsafe { - let user_key_start = Instant::now(); - let user_key = &*spectre_api_sys::spectre_user_key( - c"test".as_ptr(), - c_string.as_ptr(), - SpectreAlgorithm::Current, - ); - let user_key_duration = Instant::now().duration_since(user_key_start); - warn!( - "User key derived in {} seconds:\n{user_key:02x?}", - user_key_duration.display_as_secs() - ); - let site_key_start = Instant::now(); - let site_key = &*spectre_api_sys::spectre_site_key( - user_key as *const SpectreUserKey, - c"example.org".as_ptr(), - SpectreCounter::Initial, - SpectreKeyPurpose::Authentication, - c"".as_ptr(), - ); - let site_key_duration = Instant::now().duration_since(site_key_start); - warn!( - "Site key derived in {} seconds:\n{site_key:02x?}", - site_key_duration.display_as_secs() - ); - - // TODO: Free memory - } - }); - run_event_loop(main).await; } diff --git a/firmware/acid-firmware/ui/login-view.slint b/firmware/acid-firmware/ui/login-view.slint index 37a9845..b20542b 100644 --- a/firmware/acid-firmware/ui/login-view.slint +++ b/firmware/acid-firmware/ui/login-view.slint @@ -5,6 +5,8 @@ import { IconButton } from "widgets/icon-button.slint"; export component LoginView inherits VerticalLayout { padding: Style.spacing; spacing: Style.spacing; + in property <[string]> usernames <=> combo_box_username.model; + callback users_clicked(); callback pw_accepted(string, string); Rectangle { } @@ -12,11 +14,12 @@ export component LoginView inherits VerticalLayout { spacing: Style.spacing; IconButton { icon: @image-url("images/users.svg"); + clicked => { + users_clicked(); + } } - combo_box_username := ComboBox { - model: ["first", "second"]; - } + combo_box_username := ComboBox { } line_edit_user_pw := LineEdit { input-type: InputType.password; diff --git a/firmware/acid-firmware/ui/main.slint b/firmware/acid-firmware/ui/main.slint index 9ba981b..28c723e 100644 --- a/firmware/acid-firmware/ui/main.slint +++ b/firmware/acid-firmware/ui/main.slint @@ -1,13 +1,3 @@ -/* -import { - Button, - VerticalBox, - LineEdit, - GridBox, - TabWidget, - Button, -} from "std-widgets.slint"; -*/ import { Button, VerticalBox, @@ -18,58 +8,90 @@ import { ComboBox, } from "std-widgets.slint"; +// Implementations of standard widgets can be found at https://github.com/slint-ui/slint/tree/master/internal/compiler/widgets + // See https://github.com/slint-ui/slint/issues/4956 for issues with fonts. import "../fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf"; import { UserSitesView } from "user-sites-view.slint"; import { Style } from "globals.slint"; import { UsersView } from "users-view.slint"; import { LoginView } from "login-view.slint"; +import { UserEditView } from "user-edit-view.slint"; export enum AppState { login, - user-sites, users, + user-edit, + user-sites, } export component AppWindow inherits Window { + // Special characters to generate pre-render glyphs for. in property dummy: "ÄÖÜÁÉÍÓÚÝŔŚĹŹĆŃĚĽŽŠČŘĎŤŇŮÅäöüáéíóúýŕśĺźćńěľžščřďťňůåß„“”‘’—–@&$%+=¡¿¢£$¥€²³¼½¬¤¦§©®™°´ˇ¨"; + // in property dummy_identicon_symbols: "╔╚╰═█░▒▓☺☻╗╝╯═◈◎◐◑◒◓☀☁☂☃☄★☆☎☏⎈⌂☘☢☣☕⌚⌛⏰⚡⛄⛅☔♔♕♖♗♘♙♚♛♜♝♞♟♨♩♪♫⚐⚑⚔⚖⚙⚠⌘⏎✄✆✈✉✌"; default-font-family: "IBM Plex Mono"; default-font-size: 16pt; height: 368px; width: 960px; + forward-focus: focus-scope; in property app-state: AppState.login; + callback escape(); + callback enter-view(view: AppState); // Login View callback login_pw_accepted <=> login_view.pw_accepted; - // Sites View + // Users View + callback users_edit_user <=> users_view.edit_user; + // User Edit View + in property user_edit_username <=> user_edit_view.username; + in-out property user_edit_identicon <=> user_edit_view.identicon; + callback user_edit_compute_identicon <=> user_edit_view.compute_identicon; + callback user_edit_confirm <=> user_edit_view.confirm; + // User Sites View in property <[StandardListViewItem]> sites <=> user_sites_view.model; callback site_pw_edited <=> user_sites_view.pw_edited; callback site_pw_accepted <=> user_sites_view.pw_accepted; - // Sites View - in property <[StandardListViewItem]> usernames <=> users_view.model; - callback user_username_edited <=> users_view.pw_edited; - callback user_username_accepted <=> users_view.pw_accepted; - VerticalBox { - width: 960px; - height: 368px; - padding: 0px; - padding-top: 120px; - padding-bottom: 8px; - Rectangle { - height: 240px; - background: #00141d; - // For debugging bounds. - // border-color: #ffcf00; - // border-width: 1px; - login_view := LoginView { - visible: app-state == AppState.login; + focus-scope := FocusScope { + key-pressed(event) => { + if event.text == "\u{1b}" { + root.escape(); + EventResult.accept + } else { + EventResult.reject } + } + vertical-box := VerticalBox { + width: 960px; + height: 368px; + padding: 0px; + padding-top: 120px; + padding-bottom: 8px; + Rectangle { + height: 240px; + background: #00141d; + // For debugging bounds. + // border-color: #ffcf00; + // border-width: 1px; + login_view := LoginView { + visible: app-state == AppState.login; + users_clicked => { + enter-view(AppState.users); + } + } - user_sites_view := UserSitesView { - visible: app-state == AppState.user-sites; - } + users_view := UsersView { + visible: app-state == AppState.users; + } - users_view := UsersView { - visible: app-state == AppState.users; + user_edit_view := UserEditView { + visible: app-state == AppState.user-edit; + cancel => { + root.escape(); + } + } + + user_sites_view := UserSitesView { + visible: app-state == AppState.user-sites; + } } } } diff --git a/firmware/acid-firmware/ui/user-edit-view.slint b/firmware/acid-firmware/ui/user-edit-view.slint new file mode 100644 index 0000000..3d841de --- /dev/null +++ b/firmware/acid-firmware/ui/user-edit-view.slint @@ -0,0 +1,64 @@ +import { LineEdit, StandardListView, Button } from "std-widgets.slint"; +import { Style } from "globals.slint"; +import { IconButton } from "widgets/icon-button.slint"; + +export component UserEditView inherits HorizontalLayout { + padding: Style.spacing; + spacing: Style.spacing; + in property username; + in-out property identicon; + callback compute_identicon(encrypted_key: string, password: string); + callback confirm(encrypted_key: string); + callback cancel <=> button_cancel.clicked; + VerticalLayout { + spacing: Style.spacing; + Rectangle { } + + Text { + text: "Enter " + username + "'s encrypted key and press Enter:"; + } + + line_edit_encrypted_key := LineEdit { + input-type: InputType.text; + placeholder-text: "Encrypted key"; + edited(text) => { + root.identicon = ""; + } + accepted(text) => { + line_edit_password.focus(); + } + } + + line_edit_password := LineEdit { + input-type: InputType.text; + placeholder-text: "Password"; + edited(text) => { + root.identicon = ""; + } + accepted(text) => { + compute_identicon(line_edit_encrypted_key.text, text); + } + } + + Text { + text: identicon.is-empty ? "" : ("Check the identicon: " + identicon); + } + + HorizontalLayout { + spacing: Style.spacing; + button_cancel := Button { + text: "Cancel"; + } + + button_confirm := Button { + text: "Confirm"; + enabled: !identicon.is-empty; + clicked => { + confirm(line_edit_encrypted_key.text); + } + } + } + + Rectangle { } + } +} diff --git a/firmware/acid-firmware/ui/users-view.slint b/firmware/acid-firmware/ui/users-view.slint index c426152..7ecac87 100644 --- a/firmware/acid-firmware/ui/users-view.slint +++ b/firmware/acid-firmware/ui/users-view.slint @@ -5,26 +5,42 @@ import { IconButton } from "widgets/icon-button.slint"; export component UsersView inherits HorizontalLayout { padding: Style.spacing; spacing: Style.spacing; - in property <[StandardListViewItem]> model <=> list_view_sites.model; - in-out property current-item <=> list_view_sites.current-item; - callback pw_edited <=> line_edit_site_pw.edited; - callback pw_accepted <=> line_edit_site_pw.accepted; + callback edit_user(username: string, new: bool); VerticalLayout { - spacing: Style.spacing; - Text { - text: "Username:"; + spacing: Style.spacing * 2; + VerticalLayout { + spacing: Style.spacing; + Text { + text: "Add new user:"; + } + + line_edit_site_pw := LineEdit { + input-type: InputType.text; + placeholder-text: "Full Name"; + accepted(text) => { + edit_user(text, true); + } + } } - line_edit_site_pw := LineEdit { - input-type: InputType.text; - placeholder-text: "Full Name"; - } + VerticalLayout { + spacing: Style.spacing; + Text { + text: "Edit existing user:"; + } - list_view_sites := StandardListView { - model: [ - { text: "Test" }, - { text: "Test" }, - ]; + FocusScope { + forward-focus: list_view_sites; + key-pressed(event) => { + if event.text == "\u{0D}" /* enter */ || event.text == " " /* space */ { + edit_user(list_view_sites.current-item, false); + EventResult.accept + } else { + EventResult.reject + } + } + list_view_sites := StandardListView { } + } } }