Build 37: fix Android — typing, install-alongside-Grim, status bar, edge padding

- 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.
This commit is contained in:
2ro
2026-06-12 21:47:54 -04:00
parent 993a438e18
commit 2cc023b905
11 changed files with 232 additions and 148 deletions
+2 -1
View File
@@ -29,7 +29,7 @@
<provider
android:name=".FileProvider"
android:authorities="mw.gri.android.fileprovider"
android:authorities="st.goblin.wallet.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
@@ -40,6 +40,7 @@
<activity
android:launchMode="singleTask"
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize|stateUnchanged"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|locale|layoutDirection|fontScale|colorMode"
android:exported="true">
@@ -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;
+11
View File
@@ -44,6 +44,8 @@ pub struct App<Platform> {
resize_direction: Option<ResizeDirection>,
/// 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<bool>,
}
impl<Platform: PlatformCallbacks> App<Platform> {
@@ -53,6 +55,7 @@ impl<Platform: PlatformCallbacks> App<Platform> {
content: Content::default(),
resize_direction: None,
first_draw: true,
status_bar_white: None,
}
}
@@ -74,6 +77,14 @@ impl<Platform: PlatformCallbacks> App<Platform> {
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
+8
View File
@@ -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! {
+4
View File
@@ -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) {}
}
+6
View File
@@ -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 {
+67 -73
View File
@@ -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;
}
+39 -41
View File
@@ -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<Wallet> {
fn identity_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) -> Option<Wallet> {
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;
}
+17 -23
View File
@@ -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);
+5 -2
View File
@@ -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<R>(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;
+58 -7
View File
@@ -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<String>,
/// Optional text color override (defaults to the theme text color).
text_color: Option<egui::Color32>,
/// 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<KeyboardEvent> = 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<KeyboardEvent> = 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<String>) -> 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.