From 3d6dbf09da6548c5bd9aa4dca5d22ed3c9fc7692 Mon Sep 17 00:00:00 2001 From: Harald Date: Wed, 28 Oct 2026 15:32:43 +0100 Subject: [PATCH] fix(android): keep the toolbar out of the status and navigation bars (Issue #50) NativeActivity draws the whole window, also below the system bars, so the top toolbar button lay under the status bar and taps there went to Android (no other notebook could be opened). InkActivity now reports the system bar and cutout insets; the app shrinks egui's screen rect to the free area and adds the side insets to the page centering. The page itself still scrolls below the bars. Co-Authored-By: Claude Opus 5.5 --- .../java/eu/pansi/inknotes/InkActivity.java | 28 +++++++++++ src/android_bridge.rs | 48 +++++++++++++++---- src/app.rs | 15 +++++- src/platform.rs | 14 ++++++ src/ui.rs | 11 +++-- 5 files changed, 101 insertions(+), 15 deletions(-) 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);