From 2cc023b9056e81c7badaa47fdbe4384e0a216c8f Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:47:54 -0400 Subject: [PATCH] =?UTF-8?q?Build=2037:=20fix=20Android=20=E2=80=94=20typin?= =?UTF-8?q?g,=20install-alongside-Grim,=20status=20bar,=20edge=20padding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Typing (critical): the Goblin UI used plain egui::TextEdit, which never received input because Grim's native-keyboard path only feeds Grim's own TextEdit widget. The native IME is also aborted by the windowing system on this app. Fix: every Goblin field now uses Grim's TextEdit, opted into Grim's own on-screen keyboard on Android via a new .soft_keyboard() builder (no native-IME dependency). Additive TextEdit options (hint_text/text_color/ body/soft_keyboard) leave Grim's defaults and existing call sites unchanged. - Install alongside Grim: FileProvider authority mw.gri.android.fileprovider → st.goblin.wallet.fileprovider (Android rejects duplicate authorities). - Status bar: app draws edge-to-edge, so dark icons vanished on the dark theme. New theme-aware JNI call sets light/dark status-bar icons per theme. - Edge padding: centered_column now keeps an 18dp side gutter on phones instead of running content flush to the screen edge. Validated on the API-36 emulator: typed into fields, padding gutter present. --- android/app/src/main/AndroidManifest.xml | 3 +- .../java/mw/gri/android/MainActivity.java | 16 +- src/gui/app.rs | 11 ++ src/gui/platform/android/mod.rs | 8 + src/gui/platform/mod.rs | 4 + src/gui/theme.rs | 6 + src/gui/views/goblin/mod.rs | 140 +++++++++--------- src/gui/views/goblin/onboarding.rs | 80 +++++----- src/gui/views/goblin/send.rs | 40 +++-- src/gui/views/goblin/widgets.rs | 7 +- src/gui/views/input/edit.rs | 65 +++++++- 11 files changed, 232 insertions(+), 148 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 66bb5ee8..099c5388 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,7 +29,7 @@ diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java index cf0fb199..59ab13f2 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -503,13 +503,27 @@ public class MainActivity extends GameActivity { // Called from native code to share data from provided path. public void shareData(String path) { File file = new File(path); - Uri uri = FileProvider.getUriForFile(this, "mw.gri.android.fileprovider", file); + Uri uri = FileProvider.getUriForFile(this, "st.goblin.wallet.fileprovider", file); Intent intent = new Intent(Intent.ACTION_SEND); intent.putExtra(Intent.EXTRA_STREAM, uri); intent.setType("text/*"); startActivity(Intent.createChooser(intent, "Share data")); } + // Called from native code to set status-bar icon color to contrast the + // in-app theme. white = light icons for a dark background. The app draws + // edge-to-edge, so the OS status-bar background is the app's own content; + // the icons must follow the theme or they vanish (dark-on-dark). + public void setStatusBarWhiteIcons(boolean white) { + runOnUiThread(() -> { + androidx.core.view.WindowInsetsControllerCompat c = + androidx.core.view.WindowCompat.getInsetsController(getWindow(), + getWindow().getDecorView()); + // isAppearanceLightStatusBars == true means DARK icons. + c.setAppearanceLightStatusBars(!white); + }); + } + // Called from native code to check if device is using dark theme. public boolean useDarkTheme() { int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; diff --git a/src/gui/app.rs b/src/gui/app.rs index fa9b4140..9bde7665 100755 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -44,6 +44,8 @@ pub struct App { resize_direction: Option, /// Flag to check if it's first draw. first_draw: bool, + /// Last status-bar icon state pushed to the platform (Android). + status_bar_white: Option, } impl App { @@ -53,6 +55,7 @@ impl App { content: Content::default(), resize_direction: None, first_draw: true, + status_bar_white: None, } } @@ -74,6 +77,14 @@ impl App { self.first_draw = false; } + // Keep the Android status-bar icons readable against the in-app theme + // (the app draws edge-to-edge over the status bar). Only on change. + let white_icons = crate::gui::theme::status_bar_white_icons(); + if self.status_bar_white != Some(white_icons) { + self.platform.set_status_bar_white_icons(white_icons); + self.status_bar_white = Some(white_icons); + } + // Handle Esc keyboard key event and platform Back button key event. let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed); if back_pressed diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index 7592b9b4..77346240 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -199,6 +199,14 @@ impl PlatformCallbacks for Android { } fn clear_user_attention(&self) {} + + fn set_status_bar_white_icons(&self, white: bool) { + self.call_java_method( + "setStatusBarWhiteIcons", + "(Z)V", + &[JValue::Bool(white as u8)], + ); + } } lazy_static! { diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index 8542ec25..3bc207be 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -43,4 +43,8 @@ pub trait PlatformCallbacks { fn request_user_attention(&self); fn user_attention_required(&self) -> bool; fn clear_user_attention(&self); + + /// Set the status-bar icon color to contrast the current theme. `white` = + /// light icons (for a dark background). No-op off Android. + fn set_status_bar_white_icons(&self, _white: bool) {} } diff --git a/src/gui/theme.rs b/src/gui/theme.rs index 432e8aec..b6331f20 100644 --- a/src/gui/theme.rs +++ b/src/gui/theme.rs @@ -196,6 +196,12 @@ pub fn tokens() -> &'static ThemeTokens { } } +/// Whether the status bar should use light (white) icons: true on the dark +/// theme (dark top), false on the light/yellow themes (bright top). +pub fn status_bar_white_icons() -> bool { + tokens().dark_base +} + /// Density scales from the design handoff. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum DensityKind { diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 1843f889..8b466e63 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -26,7 +26,7 @@ use egui::{Align, Color32, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; use crate::gui::icons::{ARROW_DOWN, CHECK, CLOCK, COPY, QR_CODE, USER_CIRCLE, WALLET}; use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; -use crate::gui::views::{Content, View}; +use crate::gui::views::{Content, TextEdit, View}; use crate::wallet::Wallet; use crate::wallet::types::WalletData; @@ -1225,8 +1225,8 @@ impl GoblinWalletView { fn me_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); match self.settings_page { - SettingsPage::Node => return self.node_settings_ui(ui, wallet), - SettingsPage::Relays => return self.relays_ui(ui, wallet), + SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb), + SettingsPage::Relays => return self.relays_ui(ui, wallet, cb), SettingsPage::Nips => return self.nips_ui(ui), SettingsPage::Pairing => return self.pairing_settings_ui(ui), SettingsPage::Main => {} @@ -1395,7 +1395,7 @@ impl GoblinWalletView { if self.claim.is_none() { self.claim = Some(ClaimState::default()); } - self.claim_ui(ui, wallet); + self.claim_ui(ui, wallet, cb); ui.add_space(8.0); w::card(ui, |ui| { if !npub.is_empty() { @@ -1454,7 +1454,7 @@ impl GoblinWalletView { } if self.import_nsec.is_some() { ui.add_space(8.0); - self.import_nsec_ui(ui, wallet); + self.import_nsec_ui(ui, wallet, cb); } ui.add_space(16.0); @@ -1643,7 +1643,7 @@ impl GoblinWalletView { }); } - fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { use crate::wallet::types::ConnectionMethod; use crate::wallet::{ConnectionsConfig, ExternalConnection}; let t = theme::tokens(); @@ -1730,21 +1730,19 @@ impl GoblinWalletView { ui.add_space(16.0); settings_group(ui, "Add external node", |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.node_url_input) - .hint_text("https://node.example.com:3413") - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("set_node_url")) + .hint_text("https://node.example.com:3413") + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut self.node_url_input, cb); ui.add_space(8.0); - ui.add( - egui::TextEdit::singleline(&mut self.node_secret_input) - .hint_text("API secret (optional)") - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("set_node_secret")) + .hint_text("API secret (optional)") + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut self.node_secret_input, cb); }); ui.add_space(10.0); let url = self.node_url_input.trim().to_string(); @@ -1770,7 +1768,7 @@ impl GoblinWalletView { } /// Relay list editor; saving restarts the nostr service live. - fn relays_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + fn relays_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); if self.sub_header(ui, "Relays") { self.settings_page = SettingsPage::Main; @@ -1821,13 +1819,12 @@ impl GoblinWalletView { ui.add_space(16.0); settings_group(ui, "Add relay", |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.relay_input) - .hint_text("wss://relay.example.com") - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("set_relay")) + .hint_text("wss://relay.example.com") + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut self.relay_input, cb); }); ui.add_space(10.0); let relay = self.relay_input.trim().to_string(); @@ -2049,24 +2046,22 @@ impl GoblinWalletView { ); ui.add_space(10.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut rotate.reset_input) - .hint_text("Type RESET") - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("rotate_reset")) + .hint_text("Type RESET") + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut rotate.reset_input, cb); }); ui.add_space(8.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut rotate.password) - .hint_text("Wallet password") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("rotate_pass")) + .hint_text("Wallet password") + .password() + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut rotate.password, cb); }); ui.add_space(10.0); let armed = rotate.reset_input.trim() == "RESET" && !rotate.password.is_empty(); @@ -2182,7 +2177,7 @@ impl GoblinWalletView { } /// Inline nsec-import flow: replaces the identity with an imported key. - fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); let import = self.import_nsec.as_mut().unwrap(); if import.stage == 3 { @@ -2222,36 +2217,33 @@ impl GoblinWalletView { ); ui.add_space(10.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut import.nsec) - .hint_text("nsec1… or identity backup JSON") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("import_nsec")) + .hint_text("nsec1… or identity backup JSON") + .password() + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut import.nsec, cb); }); ui.add_space(8.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut import.password) - .hint_text("Wallet password") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("import_pass")) + .hint_text("Wallet password") + .password() + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut import.password, cb); }); ui.add_space(8.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut import.backup_password) - .hint_text("Backup password (only if exported elsewhere)") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("import_backup_pass")) + .hint_text("Backup password (only if exported elsewhere)") + .password() + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut import.backup_password, cb); }); ui.add_space(10.0); let pasted = import.nsec.trim(); @@ -2357,7 +2349,7 @@ impl GoblinWalletView { } /// Inline username-claim widget (availability check + register over Tor). - fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); // Poll the worker result; avatar invalidation happens after the // claim borrow is released. @@ -2530,12 +2522,14 @@ impl GoblinWalletView { .font(FontId::new(16.0, fonts::semibold())) .color(t.surface_text), ); - let edit = egui::TextEdit::singleline(&mut claim.input) + let before = claim.input.clone(); + TextEdit::new(egui::Id::from("settings_claim")) .hint_text("yourname") - .desired_width(ui.available_width() - 20.0) .text_color(t.surface_text) - .frame(false); - if ui.add(edit).changed() { + .body() + .soft_keyboard() + .ui(ui, &mut claim.input, cb); + if claim.input != before { claim.available = None; claim.message = None; } diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index 21718f8f..3a453f26 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -26,7 +26,7 @@ use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult}; use crate::gui::views::wallets::creation::MnemonicSetup; -use crate::gui::views::{CameraScanContent, Content, Modal, View}; +use crate::gui::views::{CameraScanContent, Content, Modal, TextEdit, View}; use crate::node::Node; use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize}; use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList}; @@ -121,11 +121,11 @@ impl OnboardingContent { ui.add_space(View::get_top_inset() + 24.0); match self.step { Step::Intro => self.intro_ui(ui), - Step::Node => self.node_ui(ui), - Step::WalletSetup => self.wallet_setup_ui(ui), + Step::Node => self.node_ui(ui, cb), + Step::WalletSetup => self.wallet_setup_ui(ui, cb), Step::Words => self.words_ui(ui, wallets, cb), Step::ConfirmWords => self.confirm_ui(ui, wallets, cb), - Step::Identity => done = self.identity_ui(ui), + Step::Identity => done = self.identity_ui(ui, cb), } ui.add_space(View::get_bottom_inset() + 24.0); }); @@ -289,7 +289,7 @@ impl OnboardingContent { .clicked() } - fn node_ui(&mut self, ui: &mut egui::Ui) { + fn node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); self.step_header( ui, @@ -320,13 +320,12 @@ impl OnboardingContent { if !self.integrated { ui.add_space(10.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.ext_url) - .hint_text("https://node.example.com") - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("onb_ext_url")) + .hint_text("https://node.example.com") + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut self.ext_url, cb); }); } ui.add_space(8.0); @@ -354,7 +353,7 @@ impl OnboardingContent { // ── Wallet name + password, create vs restore ─────────────────────── - fn wallet_setup_ui(&mut self, ui: &mut egui::Ui) { + fn wallet_setup_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); self.step_header(ui, "STEP 2 OF 3 · WALLET", "Set up your wallet", Step::Node); @@ -381,35 +380,32 @@ impl OnboardingContent { ui.add_space(14.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.name) - .hint_text("Wallet name") - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("onb_name")) + .hint_text("Wallet name") + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut self.name, cb); }); ui.add_space(8.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.pass) - .hint_text("Password") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("onb_pass")) + .hint_text("Password") + .password() + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut self.pass, cb); }); ui.add_space(8.0); w::field_well(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.pass2) - .hint_text("Repeat password") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + TextEdit::new(egui::Id::from("onb_pass2")) + .hint_text("Repeat password") + .password() + .text_color(t.surface_text) + .body() + .soft_keyboard() + .ui(ui, &mut self.pass2, cb); }); ui.add_space(10.0); ui.label( @@ -663,7 +659,7 @@ impl OnboardingContent { // ── Identity (optional username) ───────────────────────────────────── - fn identity_ui(&mut self, ui: &mut egui::Ui) -> Option { + fn identity_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) -> Option { let t = theme::tokens(); // No back from here: the wallet exists now. ui.label( @@ -798,12 +794,14 @@ impl OnboardingContent { .font(FontId::new(16.0, fonts::semibold())) .color(t.surface_text), ); - let edit = egui::TextEdit::singleline(&mut self.claim.input) + let before = self.claim.input.clone(); + TextEdit::new(egui::Id::from("onb_claim")) .hint_text("yourname") - .desired_width(ui.available_width()) .text_color(t.surface_text) - .frame(false); - if ui.add(edit).changed() { + .body() + .soft_keyboard() + .ui(ui, &mut self.claim.input, cb); + if self.claim.input != before { self.claim.available = None; self.claim.message = None; } diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 0d9d5ea9..b75faeda 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -18,11 +18,11 @@ use eframe::epaint::FontId; use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2}; use grin_core::core::amount_from_hr_string; -use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, QR_CODE, USERS}; +use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, USERS}; use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; use crate::gui::views::types::QrScanResult; -use crate::gui::views::{CameraContent, View}; +use crate::gui::views::{CameraContent, TextEdit, View}; use crate::nostr::nip05; use crate::wallet::Wallet; use crate::wallet::types::WalletTask; @@ -200,7 +200,7 @@ impl SendFlow { crate::gui::views::Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.stage { Stage::Recipient => done = self.recipient_ui(ui, wallet, cb, avatars), - Stage::Amount => done = self.amount_ui(ui, wallet, avatars), + Stage::Amount => done = self.amount_ui(ui, wallet, avatars, cb), Stage::Review => done = self.review_ui(ui, wallet, avatars), Stage::Sending => self.sending_ui(ui, wallet), Stage::Success => done = self.success_ui(ui), @@ -284,27 +284,18 @@ impl SendFlow { .color(t.surface_text_mute), ); ui.add_space(8.0); - let edit = egui::TextEdit::singleline(&mut search) + let mut te = TextEdit::new(egui::Id::from("send_search")) .hint_text("@handle, npub, or name") - .desired_width(ui.available_width() - 34.0) .text_color(t.surface_text) - .frame(false); - ui.add(edit); - let scan_btn = ui - .add( - egui::Label::new( - RichText::new(QR_CODE) - .font(FontId::new(20.0, fonts::regular())) - .color(t.surface_text), - ) - .sense(Sense::click()), - ) - .on_hover_cursor(egui::CursorIcon::PointingHand); - open_scan = scan_btn.clicked(); + .body() + .soft_keyboard() + .scan_qr(); + te.ui(ui, &mut search, cb); + // scan_qr() already starts the camera on tap. + open_scan = te.scan_pressed; }); }); if open_scan { - cb.start_camera(); self.scan = Some(CameraContent::default()); self.error = None; } @@ -716,6 +707,7 @@ impl SendFlow { ui: &mut egui::Ui, wallet: &Wallet, avatars: &mut AvatarTextures, + cb: &dyn PlatformCallbacks, ) -> bool { let t = theme::tokens(); if self.back_header(ui, "Amount") { @@ -796,12 +788,14 @@ impl SendFlow { .color(t.surface_text_dim), ); ui.add_space(8.0); - let edit = egui::TextEdit::singleline(&mut self.note) + let note_id = egui::Id::from("send_note"); + TextEdit::new(note_id) .hint_text("Add a note…") - .desired_width(ui.available_width()) .text_color(t.surface_text) - .frame(false); - note_focused = ui.add(edit).has_focus(); + .body() + .soft_keyboard() + .ui(ui, &mut self.note, cb); + note_focused = ui.ctx().memory(|m| m.has_focus(note_id)); }); }); ui.add_space(12.0); diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 679827db..7d240f78 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -643,9 +643,12 @@ pub fn fill_bg(ui: &Ui, color: Color32) { /// would start the row a single line tall, so a `ScrollArea` inside would /// clip everything below the first widget. pub fn centered_column(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + // Always keep a side gutter so content never runs flush to the screen + // edge on phones (where `width` exceeds the available width). + const MIN_SIDE_PAD: f32 = 18.0; let avail = ui.available_width(); - let w = width.min(avail); - let margin = ((avail - w) / 2.0).max(0.0); + let w = width.min(avail - MIN_SIDE_PAD * 2.0).max(0.0); + let margin = ((avail - w) / 2.0).max(MIN_SIDE_PAD); let mut rect = ui.available_rect_before_wrap(); rect.min.x += margin; rect.max.x = rect.min.x + w; diff --git a/src/gui/views/input/edit.rs b/src/gui/views/input/edit.rs index 4176d31a..e81a9501 100644 --- a/src/gui/views/input/edit.rs +++ b/src/gui/views/input/edit.rs @@ -52,6 +52,12 @@ pub struct TextEdit { numeric: bool, /// Flag to not show soft keyboard. no_soft_keyboard: bool, + /// Optional placeholder shown when empty. + hint: Option, + /// Optional text color override (defaults to the theme text color). + text_color: Option, + /// Use the body text style instead of the default heading. + body_font: bool, } impl TextEdit { @@ -73,6 +79,9 @@ impl TextEdit { enter_pressed: false, numeric: false, no_soft_keyboard: is_android(), + hint: None, + text_color: None, + body_font: false, } } @@ -172,14 +181,21 @@ impl TextEdit { // Show text edit. let text_edit_resp = egui::TextEdit::singleline(input) - .text_color(if self.enabled { - Colors::text(false) - } else { + .hint_text(self.hint.clone().unwrap_or_default()) + .text_color(if !self.enabled { Colors::inactive_text() + } else if let Some(c) = self.text_color { + c + } else { + Colors::text(false) }) .interactive(self.enabled) .id(self.id) - .font(TextStyle::Heading) + .font(if self.body_font { + TextStyle::Body + } else { + TextStyle::Heading + }) .min_size(edit_rect.size()) .margin(if View::is_desktop() { egui::Margin::symmetric(4, 2) @@ -206,8 +222,14 @@ impl TextEdit { // Reset keyboard state for newly focused. if clicked || self.focus_request { - ui.ctx() - .send_viewport_cmd(ViewportCommand::IMEAllowed(true)); + // Only ask for the native IME when this field uses it. + // Fields that opt into the on-screen keyboard must not + // also trip the (flaky, on some devices aborted) native + // soft keyboard. + if self.no_soft_keyboard { + ui.ctx() + .send_viewport_cmd(ViewportCommand::IMEAllowed(true)); + } KeyboardContent::reset_window_state(); } @@ -247,7 +269,10 @@ impl TextEdit { multiline: bool, value: &mut String, ) -> bool { - let event: Option = if is_android() { + // Native IME (Grim's default on Android) feeds LAST_SOFT_KEYBOARD_EVENT; + // the on-screen keyboard feeds KeyboardContent. A field using the + // on-screen keyboard reads the latter even on Android. + let event: Option = if is_android() && self.no_soft_keyboard { let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write(); w_input.take() } else { @@ -411,6 +436,32 @@ impl TextEdit { self.no_soft_keyboard = true; self } + + /// Use the on-screen keyboard on every platform, including Android (where + /// the default is the native IME). Reliable when the native keyboard is + /// unavailable or aborted by the windowing system. + pub fn soft_keyboard(mut self) -> Self { + self.no_soft_keyboard = false; + self + } + + /// Set placeholder text shown when the field is empty. + pub fn hint_text(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// Override the text color (e.g. to match an on-card theme token). + pub fn text_color(mut self, color: egui::Color32) -> Self { + self.text_color = Some(color); + self + } + + /// Render with the body text style instead of the default heading. + pub fn body(mut self) -> Self { + self.body_font = true; + self + } } /// Check if current system is Android.