diff --git a/packaging/android/java/eu/pansi/inknotes/InkActivity.java b/packaging/android/java/eu/pansi/inknotes/InkActivity.java index b9e02da..98723f4 100644 --- a/packaging/android/java/eu/pansi/inknotes/InkActivity.java +++ b/packaging/android/java/eu/pansi/inknotes/InkActivity.java @@ -5,11 +5,13 @@ import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.provider.OpenableColumns; import android.text.InputType; import android.view.View; import android.view.ViewGroup; +import android.view.WindowInsets; import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; @@ -29,6 +31,9 @@ import java.io.OutputStream; * An invisible view with its own input connection passes committed text to Rust. * - Picking files: the system file dialog (ACTION_OPEN_DOCUMENT); the chosen file is copied to * the cache folder, as Rust can only open files by path. + * - System bars: NativeActivity draws the whole window, also below the status and navigation + * bars and display cutouts. Their sizes go to Rust, which keeps its buttons out of them + * (Issue #50). * * Rust calls showKeyboard and pickFile through JNI and receives the native* calls * (src/android_bridge.rs). @@ -49,6 +54,9 @@ public class InkActivity extends NativeActivity { /** Path of the copied file, or null if nothing was chosen or copying failed. */ static native void nativeFilePicked(String path); + /** Window edges covered by system bars or cutouts, in pixels. */ + static native void nativeSetInsets(int left, int top, int right, int bottom); + private TextSink textSink; @Override @@ -56,6 +64,26 @@ public class InkActivity extends NativeActivity { super.onCreate(savedInstanceState); textSink = new TextSink(this); addContentView(textSink, new ViewGroup.LayoutParams(1, 1)); + View decor = getWindow().getDecorView(); + decor.setOnApplyWindowInsetsListener((view, insets) -> { + reportInsets(insets); + return view.onApplyWindowInsets(insets); + }); + decor.requestApplyInsets(); + } + + @SuppressWarnings("deprecation") + private static void reportInsets(WindowInsets insets) { + if (Build.VERSION.SDK_INT >= 30) { + android.graphics.Insets bars = insets.getInsets( + WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); + nativeSetInsets(bars.left, bars.top, bars.right, bars.bottom); + } else { + // Before Android 11 the system window insets include the bars (and since + // Android 9 the cutout) + nativeSetInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), + insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()); + } } /** Shows or hides the soft keyboard; called from Rust on any thread. */ diff --git a/src/android_bridge.rs b/src/android_bridge.rs index f0b52d9..cfd6129 100644 --- a/src/android_bridge.rs +++ b/src/android_bridge.rs @@ -6,6 +6,7 @@ //! below, possibly on another thread, so they only queue messages and wake the event loop. use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use jni::objects::{JObject, JString, JValue}; @@ -24,15 +25,23 @@ enum Message { static MESSAGES: Mutex> = Mutex::new(Vec::new()); /// What the open file dialog is for static PICKING: Mutex> = Mutex::new(None); +/// Window edges covered by system bars or cutouts (left, top, right, bottom; pixels) +static INSETS: Mutex<[f32; 4]> = Mutex::new([0.0; 4]); +/// The insets changed since the last frame +static INSETS_CHANGED: AtomicBool = AtomicBool::new(false); + +fn wake() { + if let Some(app) = crate::platform::android_app() { + app.create_waker().wake(); + } +} fn push(message: Message) { MESSAGES .lock() .unwrap_or_else(|e| e.into_inner()) .push(message); - if let Some(app) = crate::platform::android_app() { - app.create_waker().wake(); - } + wake(); } #[no_mangle] @@ -75,16 +84,39 @@ pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeFilePicked( push(Message::Picked(path)); } -/// Is a message waiting (then the app draws a new frame to take it)? +#[no_mangle] +pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeSetInsets( + _env: JNIEnv, + _class: JObject, + left: jint, + top: jint, + right: jint, + bottom: jint, +) { + let insets = [left, top, right, bottom].map(|v| v.max(0) as f32); + *INSETS.lock().unwrap_or_else(|e| e.into_inner()) = insets; + INSETS_CHANGED.store(true, Ordering::Relaxed); + wake(); +} + +/// Window edges covered by the status bar, the navigation bar or display cutouts (left, top, +/// right, bottom; physical pixels) +pub fn insets() -> [f32; 4] { + *INSETS.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Is a message waiting or did the insets change (then the app draws a new frame)? pub fn has_messages() -> bool { - !MESSAGES - .lock() - .unwrap_or_else(|e| e.into_inner()) - .is_empty() + INSETS_CHANGED.load(Ordering::Relaxed) + || !MESSAGES + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_empty() } /// Typed text and deletions as egui input, and the action for a picked file. pub fn take_messages() -> (Vec, Vec) { + INSETS_CHANGED.store(false, Ordering::Relaxed); let messages = std::mem::take(&mut *MESSAGES.lock().unwrap_or_else(|e| e.into_inner())); let mut events = Vec::new(); let mut actions = Vec::new(); diff --git a/src/app.rs b/src/app.rs index 5528a06..c5956af 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4150,6 +4150,17 @@ pub fn run_with( raw_input.events.extend(events); ui_state.queued_actions.extend(actions); } + // Keep the UI out of system bars and cutouts (Android, Issue #50) + let safe_area = platform::safe_area_insets(); + if let Some(rect) = raw_input.screen_rect.filter(|_| safe_area != [0.0; 4]) + { + let ppp = egui_ctx.pixels_per_point(); + let [left, top, right, bottom] = safe_area.map(|v| v / ppp); + raw_input.screen_rect = Some(egui::Rect::from_min_max( + rect.min + egui::vec2(left, top), + rect.max - egui::vec2(right, bottom), + )); + } egui_ctx.begin_frame(raw_input); // 2. Auswahl-Overlay rendern (Bounding Box, Griffe, Lasso) @@ -4182,8 +4193,8 @@ pub fn run_with( let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32; let (inset_left, inset_right) = match ui_state.sidebar_side { - SidebarSide::Left => (reserved, 0.0), - SidebarSide::Right => (0.0, reserved), + SidebarSide::Left => (reserved + safe_area[0], safe_area[2]), + SidebarSide::Right => (safe_area[0], reserved + safe_area[2]), }; if canvas.set_ui_insets(inset_left, inset_right) { window.request_redraw(); diff --git a/src/platform.rs b/src/platform.rs index 40a1d2e..73916b8 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -143,6 +143,20 @@ pub fn android_app() -> Option { ANDROID_APP.get().cloned() } +/// Window edges (left, top, right, bottom; physical pixels) covered by system bars or display +/// cutouts. Android: the app draws below the status and navigation bars, so its buttons must +/// keep out of them (Issue #50). Desktop: none. +pub fn safe_area_insets() -> [f32; 4] { + #[cfg(target_os = "android")] + { + crate::android_bridge::insets() + } + #[cfg(not(target_os = "android"))] + { + [0.0; 4] + } +} + /// Makes an exported file available to the user. Desktop: it already is (the path is /// returned). Android: copied to `Download/ink-notes` through the MediaStore (Android 10 and /// newer), as other apps can't see the app's own folder; older versions keep it there. diff --git a/src/ui.rs b/src/ui.rs index 45300dd..99bf049 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -497,8 +497,9 @@ pub struct UiState { pub scrollbar_grab: Option, /// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests) pub page_view: Option, - /// Fenstergröße in logischen Punkten (vom letzten Bild) - pub screen_size: egui::Vec2, + /// Usable part of the window in logical points (from the last frame; on Android without the + /// system bars) + pub screen_rect: egui::Rect, /// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört pub page_title_input: String, pub page_title_for: Option, @@ -593,7 +594,7 @@ impl Default for UiState { ruler_drag: None, scrollbar_grab: None, page_view: None, - screen_size: egui::Vec2::ZERO, + screen_rect: egui::Rect::ZERO, page_title_input: String::new(), page_title_for: None, page_picker_list: false, @@ -666,7 +667,7 @@ impl UiState { /// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte). fn visible_canvas_center(&self) -> egui::Pos2 { - (self.screen_size / 2.0).to_pos2() + self.screen_rect.center() + egui::vec2( match self.sidebar_side { SidebarSide::Left => self.sidebar_reserved_width() / 2.0, @@ -3200,7 +3201,7 @@ pub fn render_ui( updater: &crate::updater::UpdaterController, ) -> Vec { let mut actions = std::mem::take(&mut state.queued_actions); - state.screen_size = ctx.screen_rect().size(); + state.screen_rect = ctx.screen_rect(); page_separators(ctx, state, notebook); page_scrollbar(ctx, state, notebook, &mut actions); space_preview(ctx, state, notebook);