- Pen vs. finger (#44): the patched winit hands Android pointers with tool type stylus/eraser to the app separately (take_pen_samples, with hover and button state) instead of as touch events. src/pen.rs turns them, like octotablet's events on the desktop, into one PenInput stream, so pen buttons, the eraser end and palm rejection work the same everywhere. Hover exit right before touch-down does not count as leaving; a pen without hover leaves 150 ms after lifting. - Soft keyboard: shown via AndroidApp::show_soft_input when a text field gets focus, hidden when it loses it. The patched winit now fills KeyEvent::text, so typed characters reach egui. - Exports (PDF, PNG, SVG) are written to the app folder and copied to Download/ink-notes through the MediaStore (Android 10+, via JNI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ca21483e5
commit
a8aef30db7
13 files changed
+736
-108
No files matched your search
Generated
+1
@@ -1556,6 +1556,7 @@ dependencies = [
|
|||||||
"hayro",
|
"hayro",
|
||||||
"hayro-svg",
|
"hayro-svg",
|
||||||
"image",
|
"image",
|
||||||
|
"jni",
|
||||||
"log",
|
"log",
|
||||||
"lopdf",
|
"lopdf",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
|
|||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
winit = { version = "0.29", features = ["rwh_06", "android-native-activity"] }
|
winit = { version = "0.29", features = ["rwh_06", "android-native-activity"] }
|
||||||
android_logger = { version = "0.13", default-features = false }
|
android_logger = { version = "0.13", default-features = false }
|
||||||
|
# Saving exports to Downloads through Android's MediaStore
|
||||||
|
jni = { version = "0.21", default-features = false }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|
||||||
[package.metadata.android]
|
[package.metadata.android]
|
||||||
|
|||||||
+1
-1
@@ -156,7 +156,7 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
|
|||||||
|
|
||||||
Andere Systeme:
|
Andere Systeme:
|
||||||
- **Windows** wird unter Linux übersetzt: `scripts/package-windows.sh` (braucht `gcc-mingw-w64-x86-64`); die Tests laufen mit `wine` als Runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
- **Windows** wird unter Linux übersetzt: `scripts/package-windows.sh` (braucht `gcc-mingw-w64-x86-64`); die Tests laufen mit `wine` als Runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
||||||
- **Android** (experimentell, Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Notizbücher bleiben vorerst in der App; Stifterkennung, Bildschirmtastatur und Dateidialoge fehlen noch.
|
- **Android** (experimentell, Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Textfelder öffnen die Bildschirmtastatur (Zeichen, die die Tastatur als Text überträgt, z. B. per langem Druck gewählte Umlaute, kommen noch nicht an), und Exporte landen in `Download/ink-notes` (ab Android 10). Dateien importieren geht noch nicht.
|
||||||
|
|
||||||
### Projektstruktur
|
### Projektstruktur
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D
|
|||||||
|
|
||||||
Other systems:
|
Other systems:
|
||||||
- **Windows** is cross-compiled on Linux: `scripts/package-windows.sh` (needs `gcc-mingw-w64-x86-64`); the tests run with `wine` as runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
- **Windows** is cross-compiled on Linux: `scripts/package-windows.sh` (needs `gcc-mingw-w64-x86-64`); the tests run with `wine` as runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
||||||
- **Android** (experimental, Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. Notebooks stay inside the app for now; pen detection, on-screen keyboard and file dialogs are still missing.
|
- **Android** (experimental, Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), text fields open the soft keyboard (characters the keyboard commits as text, e.g. umlauts chosen by long press, don't arrive yet), and exports are saved to `Download/ink-notes` (Android 10 and newer). Importing files is still missing.
|
||||||
|
|
||||||
New code comments and commit messages are written in English; older code and history still contain German.
|
New code comments and commit messages are written in English; older code and history still contain German.
|
||||||
|
|
||||||
|
|||||||
+59
-102
@@ -41,6 +41,7 @@ use crate::image_layer::TextureSource;
|
|||||||
use crate::library::Library;
|
use crate::library::Library;
|
||||||
use crate::osk::{self, KeyboardRequester};
|
use crate::osk::{self, KeyboardRequester};
|
||||||
use crate::pdf_doc::PdfRenderService;
|
use crate::pdf_doc::PdfRenderService;
|
||||||
|
use crate::pen::PenEvent;
|
||||||
use crate::platform;
|
use crate::platform;
|
||||||
use crate::selection::{
|
use crate::selection::{
|
||||||
render_selection_overlay, scale_factors, DragMode, SelectionHandleHit, SelectionState,
|
render_selection_overlay, scale_factors, DragMode, SelectionHandleHit, SelectionState,
|
||||||
@@ -171,17 +172,6 @@ fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kennung einer Stifttaste, die über Programmstarts hinweg gleich bleibt (die Tablet-
|
|
||||||
/// Schnittstelle liefert nur eine undurchsichtige, aber vergleichbare Kennung).
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
fn stylus_button_key(id: &octotablet::tool::ButtonID) -> u64 {
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
// DefaultHasher::new() verwendet feste Schlüssel: gleiche Taste, gleicher Wert
|
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
||||||
id.hash(&mut hasher);
|
|
||||||
hasher.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein.
|
/// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein.
|
||||||
fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) {
|
fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) {
|
||||||
ui_state.available_notebooks = storage::list_notebooks(base_dir);
|
ui_state.available_notebooks = storage::list_notebooks(base_dir);
|
||||||
@@ -309,7 +299,7 @@ fn start_pdf_export(
|
|||||||
),
|
),
|
||||||
Err(err) => eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"),
|
Err(err) => eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"),
|
||||||
}
|
}
|
||||||
let _ = result_tx.send(result.map(|()| export_path));
|
let _ = result_tx.send(result.and_then(|()| platform::publish_export(export_path)));
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1089,7 @@ fn start_page_export(
|
|||||||
let result = bytes
|
let result = bytes
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
.and_then(|bytes| std::fs::write(&path, bytes).map_err(|e| e.to_string()))
|
.and_then(|bytes| std::fs::write(&path, bytes).map_err(|e| e.to_string()))
|
||||||
.map(|()| path);
|
.and_then(|()| platform::publish_export(path));
|
||||||
let _ = result_tx.send(result);
|
let _ = result_tx.send(result);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
});
|
});
|
||||||
@@ -1532,8 +1522,9 @@ pub fn run_with(
|
|||||||
// that touch it while the pen is near, are ignored until they are lifted
|
// that touch it while the pen is near, are ignored until they are lifted
|
||||||
let mut palm_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
let mut palm_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||||
let mut stylus_left_at: Option<Instant> = None;
|
let mut stylus_left_at: Option<Instant> = None;
|
||||||
#[cfg(not(target_os = "android"))]
|
let mut stylus_tool_id: Option<u64> = None;
|
||||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
#[cfg(target_os = "android")]
|
||||||
|
let mut android_pen = crate::pen::AndroidPen::default();
|
||||||
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
|
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
|
||||||
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
|
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
|
||||||
let mut stylus_held: Option<(u64, StylusAction)> = None;
|
let mut stylus_held: Option<(u64, StylusAction)> = None;
|
||||||
@@ -1760,54 +1751,44 @@ pub fn run_with(
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stylus-/Tablet-Events via Octotablet abfragen (Wayland/X11 Tablet-Protokoll für aktive Stifte wie Lazarite)
|
// Pen input (Issue #44): octotablet on the desktop (Wayland/X11 tablet protocols,
|
||||||
|
// Windows Ink), on Android the pen's own touch samples
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
if let Some(ref mut mgr) = tablet_manager {
|
let pen_events = match tablet_manager.as_mut().map(|mgr| mgr.pump()) {
|
||||||
// Infallible on Windows
|
Some(Ok(events)) => crate::pen::from_octotablet(events),
|
||||||
#[allow(irrefutable_let_patterns)]
|
_ => Vec::new(),
|
||||||
if let Ok(tablet_events) = mgr.pump() {
|
};
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
let pen_events = {
|
||||||
|
let samples: Vec<crate::pen::Sample> =
|
||||||
|
winit::platform::android::take_pen_samples()
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect();
|
||||||
|
android_pen.convert(&samples, window.scale_factor() as f32, Instant::now())
|
||||||
|
};
|
||||||
|
if !pen_events.is_empty() {
|
||||||
let scale = window.scale_factor() as f32;
|
let scale = window.scale_factor() as f32;
|
||||||
let now = start_time.elapsed().as_secs_f64();
|
let now = start_time.elapsed().as_secs_f64();
|
||||||
// Aufsetzen erst mit der Position desselben Frames verarbeiten
|
for crate::pen::PenInput {
|
||||||
let tablet_events = crate::platform::down_after_pose(
|
tool,
|
||||||
tablet_events,
|
is_eraser: tool_is_hardware_eraser,
|
||||||
|e| {
|
event,
|
||||||
matches!(
|
} in pen_events
|
||||||
e,
|
{
|
||||||
octotablet::events::Event::Tool {
|
|
||||||
event: octotablet::events::ToolEvent::Down,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|e| {
|
|
||||||
matches!(
|
|
||||||
e,
|
|
||||||
octotablet::events::Event::Tool {
|
|
||||||
event: octotablet::events::ToolEvent::Pose(_),
|
|
||||||
..
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
for t_event in tablet_events {
|
|
||||||
if let octotablet::events::Event::Tool { tool, event } = t_event {
|
|
||||||
let tool_is_hardware_eraser =
|
|
||||||
tool.tool_type == Some(octotablet::tool::Type::Eraser);
|
|
||||||
|
|
||||||
let pen_arrives = !is_stylus_in_proximity
|
let pen_arrives = !is_stylus_in_proximity
|
||||||
&& matches!(
|
&& matches!(
|
||||||
event,
|
event,
|
||||||
octotablet::events::ToolEvent::In { .. }
|
PenEvent::In | PenEvent::Pose { .. } | PenEvent::Down
|
||||||
| octotablet::events::ToolEvent::Pose(_)
|
|
||||||
| octotablet::events::ToolEvent::Down
|
|
||||||
);
|
);
|
||||||
if pen_arrives {
|
if pen_arrives {
|
||||||
// Fingers already on the screen are most likely the writing hand:
|
// Fingers already on the screen are most likely the writing hand:
|
||||||
// undo what they started and ignore them until lifted
|
// undo what they started and ignore them until lifted
|
||||||
if debug_fling && !active_touches.is_empty() {
|
if debug_fling && !active_touches.is_empty() {
|
||||||
eprintln!("[Nachgleiten] Stift kommt: {} Finger als Handballen ignoriert", active_touches.len());
|
eprintln!(
|
||||||
|
"[Nachgleiten] Stift kommt: {} Finger als Handballen ignoriert",
|
||||||
|
active_touches.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
palm_touch_ids.extend(active_touches.drain().map(|(id, _)| id));
|
palm_touch_ids.extend(active_touches.drain().map(|(id, _)| id));
|
||||||
long_press.cancel();
|
long_press.cancel();
|
||||||
@@ -1820,9 +1801,7 @@ pub fn run_with(
|
|||||||
is_touch_spacing = false;
|
is_touch_spacing = false;
|
||||||
ui_state.space_drag = None;
|
ui_state.space_drag = None;
|
||||||
}
|
}
|
||||||
if selection_state.drag_mode != DragMode::None
|
if selection_state.drag_mode != DragMode::None && !is_stylus_selecting {
|
||||||
&& !is_stylus_selecting
|
|
||||||
{
|
|
||||||
selection_release(
|
selection_release(
|
||||||
false,
|
false,
|
||||||
&mut notebook,
|
&mut notebook,
|
||||||
@@ -1845,12 +1824,11 @@ pub fn run_with(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
octotablet::events::ToolEvent::In { .. } => {
|
PenEvent::In => {
|
||||||
is_stylus_in_proximity = true;
|
is_stylus_in_proximity = true;
|
||||||
stylus_tool_id = Some(tool.id());
|
stylus_tool_id = Some(tool);
|
||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Out
|
PenEvent::Out => {
|
||||||
| octotablet::events::ToolEvent::Removed => {
|
|
||||||
if is_stylus_in_proximity {
|
if is_stylus_in_proximity {
|
||||||
stylus_left_at = Some(Instant::now());
|
stylus_left_at = Some(Instant::now());
|
||||||
platform::ignore_decoration_touches_until(Some(
|
platform::ignore_decoration_touches_until(Some(
|
||||||
@@ -1871,7 +1849,7 @@ pub fn run_with(
|
|||||||
input.events.push(egui::Event::PointerGone);
|
input.events.push(egui::Event::PointerGone);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
if stylus_tool_id == Some(tool.id()) {
|
if stylus_tool_id == Some(tool) {
|
||||||
stylus_tool_id = None;
|
stylus_tool_id = None;
|
||||||
stylus_pan_last = None;
|
stylus_pan_last = None;
|
||||||
if is_stylus_drawing {
|
if is_stylus_drawing {
|
||||||
@@ -1921,16 +1899,10 @@ pub fn run_with(
|
|||||||
ui_state.pressed_stylus_button = None;
|
ui_state.pressed_stylus_button = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Button {
|
PenEvent::Button { key, pressed } => {
|
||||||
button_id,
|
|
||||||
pressed,
|
|
||||||
} => {
|
|
||||||
// Tasten werden beim ersten Drücken gelernt (Standard: Radieren)
|
// Tasten werden beim ersten Drücken gelernt (Standard: Radieren)
|
||||||
let key = stylus_button_key(&button_id);
|
let mut index =
|
||||||
let mut index = ui_state
|
ui_state.stylus_buttons.iter().position(|b| b.id == key);
|
||||||
.stylus_buttons
|
|
||||||
.iter()
|
|
||||||
.position(|b| b.id == key);
|
|
||||||
if index.is_none()
|
if index.is_none()
|
||||||
&& pressed
|
&& pressed
|
||||||
&& ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS
|
&& ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS
|
||||||
@@ -1940,8 +1912,7 @@ pub fn run_with(
|
|||||||
action: StylusAction::Erase,
|
action: StylusAction::Erase,
|
||||||
});
|
});
|
||||||
index = Some(ui_state.stylus_buttons.len() - 1);
|
index = Some(ui_state.stylus_buttons.len() - 1);
|
||||||
ui_settings.stylus_buttons =
|
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
|
||||||
ui_state.stylus_buttons.clone();
|
|
||||||
save_ui_settings(
|
save_ui_settings(
|
||||||
&ui_settings,
|
&ui_settings,
|
||||||
ui_settings_path.as_deref(),
|
ui_settings_path.as_deref(),
|
||||||
@@ -1973,14 +1944,14 @@ pub fn run_with(
|
|||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Pose(pose) => {
|
PenEvent::Pose { position, pressure } => {
|
||||||
is_stylus_in_proximity = true;
|
is_stylus_in_proximity = true;
|
||||||
let sx = pose.position[0] * scale;
|
let sx = position[0] * scale;
|
||||||
let sy = pose.position[1] * scale;
|
let sy = position[1] * scale;
|
||||||
stylus_screen_pos = (sx, sy);
|
stylus_screen_pos = (sx, sy);
|
||||||
cursor_screen_pos = (sx, sy);
|
cursor_screen_pos = (sx, sy);
|
||||||
|
|
||||||
if let Some(p) = pose.pressure.get() {
|
if let Some(p) = pressure {
|
||||||
current_pressure = ui_state.pressure_curve.apply(p);
|
current_pressure = ui_state.pressure_curve.apply(p);
|
||||||
ui_state.last_raw_pressure = Some(p);
|
ui_state.last_raw_pressure = Some(p);
|
||||||
}
|
}
|
||||||
@@ -2051,8 +2022,7 @@ pub fn run_with(
|
|||||||
} else if is_stylus_drawing && is_drawing {
|
} else if is_stylus_drawing && is_drawing {
|
||||||
let dx = sx - stroke_hold_anchor_pos.0;
|
let dx = sx - stroke_hold_anchor_pos.0;
|
||||||
let dy = sy - stroke_hold_anchor_pos.1;
|
let dy = sy - stroke_hold_anchor_pos.1;
|
||||||
if (dx * dx + dy * dy).sqrt() > hold_tolerance(&window)
|
if (dx * dx + dy * dy).sqrt() > hold_tolerance(&window) {
|
||||||
{
|
|
||||||
stroke_last_motion_time = Instant::now();
|
stroke_last_motion_time = Instant::now();
|
||||||
stroke_hold_anchor_pos = (sx, sy);
|
stroke_hold_anchor_pos = (sx, sy);
|
||||||
}
|
}
|
||||||
@@ -2069,17 +2039,13 @@ pub fn run_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Down => {
|
PenEvent::Down => {
|
||||||
is_stylus_in_proximity = true;
|
is_stylus_in_proximity = true;
|
||||||
stylus_tool_id = Some(tool.id());
|
stylus_tool_id = Some(tool);
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
|
||||||
if ui::is_screen_point_in_ui(
|
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||||
&egui_ctx,
|
{
|
||||||
&ui_state,
|
|
||||||
(sx, sy),
|
|
||||||
scale,
|
|
||||||
) {
|
|
||||||
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
||||||
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
|
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
|
||||||
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||||
@@ -2146,18 +2112,11 @@ pub fn run_with(
|
|||||||
|| button_action == Some(StylusAction::Erase)
|
|| button_action == Some(StylusAction::Erase)
|
||||||
|| ui_state.active_tool == ActiveTool::Eraser;
|
|| ui_state.active_tool == ActiveTool::Eraser;
|
||||||
|
|
||||||
if !should_erase
|
if !should_erase && ui_state.active_tool == ActiveTool::Space {
|
||||||
&& ui_state.active_tool == ActiveTool::Space
|
is_stylus_spacing =
|
||||||
{
|
space_press((sx, sy), &canvas, ¬ebook, &mut ui_state);
|
||||||
is_stylus_spacing = space_press(
|
|
||||||
(sx, sy),
|
|
||||||
&canvas,
|
|
||||||
¬ebook,
|
|
||||||
&mut ui_state,
|
|
||||||
);
|
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
} else if !should_erase
|
} else if !should_erase && ui_state.active_tool == ActiveTool::Text
|
||||||
&& ui_state.active_tool == ActiveTool::Text
|
|
||||||
{
|
{
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
text_tool_click(
|
text_tool_click(
|
||||||
@@ -2232,7 +2191,7 @@ pub fn run_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Up => {
|
PenEvent::Up => {
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
if stylus_ui_capture {
|
if stylus_ui_capture {
|
||||||
stylus_ui_capture = false;
|
stylus_ui_capture = false;
|
||||||
@@ -2299,8 +2258,7 @@ pub fn run_with(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapped_shape =
|
let snapped_shape = stroke_snapped_shape.take().or_else(|| {
|
||||||
stroke_snapped_shape.take().or_else(|| {
|
|
||||||
if ui_state.active_tool == ActiveTool::Shape {
|
if ui_state.active_tool == ActiveTool::Shape {
|
||||||
geometry::detect_shape(&canvas.live_samples)
|
geometry::detect_shape(&canvas.live_samples)
|
||||||
} else {
|
} else {
|
||||||
@@ -2324,9 +2282,6 @@ pub fn run_with(
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5495,6 +5450,8 @@ pub fn run_with(
|
|||||||
let pressed = egui_ctx.input(|i| i.pointer.any_pressed());
|
let pressed = egui_ctx.input(|i| i.pointer.any_pressed());
|
||||||
if osk::should_request(ime_was_active, ime_active, pressed) {
|
if osk::should_request(ime_was_active, ime_active, pressed) {
|
||||||
requester.request();
|
requester.request();
|
||||||
|
} else if ime_was_active && !ime_active {
|
||||||
|
requester.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ime_was_active = ime_active;
|
ime_was_active = ime_active;
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ pub fn sanitize_filename_component(title: &str) -> String {
|
|||||||
/// `~/.config/user-dirs.dirs` (z. B. „Dokumente“), sonst `~/Documents`. Im Flatpak ist nur
|
/// `~/.config/user-dirs.dirs` (z. B. „Dokumente“), sonst `~/Documents`. Im Flatpak ist nur
|
||||||
/// dieser Ordner beschreibbar, der Fallback allein träfe bei deutschen Systemen daneben.
|
/// dieser Ordner beschreibbar, der Fallback allein träfe bei deutschen Systemen daneben.
|
||||||
pub fn default_documents_dir() -> PathBuf {
|
pub fn default_documents_dir() -> PathBuf {
|
||||||
|
// Android: written into the app's own folder first, then copied to Downloads
|
||||||
|
// (`platform::publish_export`)
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
if let Some(dir) = crate::platform::android_app().and_then(|app| app.external_data_path()) {
|
||||||
|
return dir.join("Documents");
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
||||||
if !dir.trim().is_empty() {
|
if !dir.trim().is_empty() {
|
||||||
return PathBuf::from(dir);
|
return PathBuf::from(dir);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod paths;
|
|||||||
pub mod pdf_doc;
|
pub mod pdf_doc;
|
||||||
pub mod pdf_merge;
|
pub mod pdf_merge;
|
||||||
pub mod pdf_writer;
|
pub mod pdf_writer;
|
||||||
|
pub mod pen;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod rnote;
|
pub mod rnote;
|
||||||
pub mod ruler;
|
pub mod ruler;
|
||||||
@@ -51,6 +52,7 @@ fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
|||||||
);
|
);
|
||||||
// Panics would otherwise only go to stderr, which Android discards
|
// Panics would otherwise only go to stderr, which Android discards
|
||||||
std::panic::set_hook(Box::new(|info| log::error!("{info}")));
|
std::panic::set_hook(Box::new(|info| log::error!("{info}")));
|
||||||
|
platform::set_android_app(app.clone());
|
||||||
if let Some(dir) = app.internal_data_path() {
|
if let Some(dir) = app.internal_data_path() {
|
||||||
paths::set_app_dir(dir);
|
paths::set_app_dir(dir);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -17,11 +17,40 @@ mod wayland;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use wayland::KeyboardRequester;
|
pub use wayland::KeyboardRequester;
|
||||||
|
|
||||||
|
/// Android: NativeActivity shows the soft keyboard only when asked to (Issue #42). The typed
|
||||||
|
/// characters arrive as key events. Characters the keyboard can't send as key events (e.g.
|
||||||
|
/// umlauts chosen by long press, which it commits as text) don't arrive.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub struct KeyboardRequester {
|
||||||
|
app: winit::platform::android::activity::AndroidApp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
impl KeyboardRequester {
|
||||||
|
/// # Safety
|
||||||
|
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||||
|
pub unsafe fn for_window(_window: &winit::window::Window) -> Option<Self> {
|
||||||
|
crate::platform::android_app().map(|app| Self { app })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request(&mut self) {
|
||||||
|
self.app.show_soft_input(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release(&mut self) {
|
||||||
|
self.app.hide_soft_input(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
|
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
pub struct KeyboardRequester;
|
pub struct KeyboardRequester;
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
impl KeyboardRequester {
|
impl KeyboardRequester {
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// Nothing to uphold; `unsafe` like the Wayland version.
|
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||||
@@ -31,6 +60,8 @@ impl KeyboardRequester {
|
|||||||
|
|
||||||
pub fn request(&mut self) {}
|
pub fn request(&mut self) {}
|
||||||
|
|
||||||
|
pub fn release(&mut self) {}
|
||||||
|
|
||||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ impl KeyboardRequester {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The text field lost focus. Nothing to do: the compositor hides the keyboard once
|
||||||
|
/// winit disables text input.
|
||||||
|
pub fn release(&mut self) {}
|
||||||
|
|
||||||
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
||||||
/// aufrufen und an die egui-Eingabe anhängen)
|
/// aufrufen und an die egui-Eingabe anhängen)
|
||||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
|
|||||||
+382
@@ -0,0 +1,382 @@
|
|||||||
|
//! Pen input independent of the platform (Issue #44).
|
||||||
|
//!
|
||||||
|
//! On the desktop the pen comes from octotablet (Wayland/X11 tablet protocols, Windows Ink),
|
||||||
|
//! on Android as touch samples with the tool type stylus or eraser, which the patched winit
|
||||||
|
//! hands over separately (`winit::platform::android::take_pen_samples`). Both are turned into
|
||||||
|
//! the same [`PenInput`] events, which the app handles in one place.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// What happened to the pen.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum PenEvent {
|
||||||
|
/// Pen comes into range (hovers above the screen)
|
||||||
|
In,
|
||||||
|
/// Pen left the range
|
||||||
|
Out,
|
||||||
|
/// Tip touches the screen (at the position of the last `Pose`)
|
||||||
|
Down,
|
||||||
|
/// Tip lifted
|
||||||
|
Up,
|
||||||
|
/// Position in logical pixels; pressure 0..=1 if known
|
||||||
|
Pose {
|
||||||
|
position: [f32; 2],
|
||||||
|
pressure: Option<f32>,
|
||||||
|
},
|
||||||
|
/// Pen button pressed or released; `key` identifies the button across sessions
|
||||||
|
Button { key: u64, pressed: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PenInput {
|
||||||
|
/// Identifies the pen (several pens can be in use on the desktop)
|
||||||
|
pub tool: u64,
|
||||||
|
/// Eraser end of the pen
|
||||||
|
pub is_eraser: bool,
|
||||||
|
pub event: PenEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_of(value: impl std::hash::Hash) -> u64 {
|
||||||
|
use std::hash::Hasher;
|
||||||
|
// DefaultHasher::new() uses fixed keys: same input, same value in every session
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
value.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns octotablet's events into [`PenInput`]s, with each `Down` after the `Pose` of the
|
||||||
|
/// same frame (see [`crate::platform::down_after_pose`]).
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub fn from_octotablet<'a>(
|
||||||
|
events: impl IntoIterator<Item = octotablet::events::Event<'a>>,
|
||||||
|
) -> Vec<PenInput> {
|
||||||
|
use octotablet::events::{Event, ToolEvent};
|
||||||
|
|
||||||
|
let inputs = events.into_iter().filter_map(|event| {
|
||||||
|
let Event::Tool { tool, event } = event else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let event = match event {
|
||||||
|
ToolEvent::In { .. } => PenEvent::In,
|
||||||
|
ToolEvent::Out | ToolEvent::Removed => PenEvent::Out,
|
||||||
|
ToolEvent::Down => PenEvent::Down,
|
||||||
|
ToolEvent::Up => PenEvent::Up,
|
||||||
|
ToolEvent::Pose(pose) => PenEvent::Pose {
|
||||||
|
position: pose.position,
|
||||||
|
pressure: pose.pressure.get(),
|
||||||
|
},
|
||||||
|
ToolEvent::Button { button_id, pressed } => PenEvent::Button {
|
||||||
|
key: hash_of(button_id),
|
||||||
|
pressed,
|
||||||
|
},
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(PenInput {
|
||||||
|
tool: hash_of(tool.id()),
|
||||||
|
is_eraser: tool.tool_type == Some(octotablet::tool::Type::Eraser),
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
crate::platform::down_after_pose(
|
||||||
|
inputs,
|
||||||
|
|i| i.event == PenEvent::Down,
|
||||||
|
|i| matches!(i.event, PenEvent::Pose { .. }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an Android pen sample reports (mirrors `winit::platform::android::PenAction`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SampleAction {
|
||||||
|
HoverEnter,
|
||||||
|
HoverMove,
|
||||||
|
HoverExit,
|
||||||
|
Down,
|
||||||
|
Move,
|
||||||
|
Up,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One Android pen sample (mirrors `winit::platform::android::PenSample`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Sample {
|
||||||
|
pub action: SampleAction,
|
||||||
|
/// Physical pixels
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub pressure: f32,
|
||||||
|
pub eraser: bool,
|
||||||
|
/// `MotionEvent.getButtonState()`
|
||||||
|
pub buttons: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
impl From<winit::platform::android::PenSample> for Sample {
|
||||||
|
fn from(s: winit::platform::android::PenSample) -> Self {
|
||||||
|
use winit::platform::android::PenAction as A;
|
||||||
|
Self {
|
||||||
|
action: match s.action {
|
||||||
|
A::HoverEnter => SampleAction::HoverEnter,
|
||||||
|
A::HoverMove => SampleAction::HoverMove,
|
||||||
|
A::HoverExit => SampleAction::HoverExit,
|
||||||
|
A::Down => SampleAction::Down,
|
||||||
|
A::Move => SampleAction::Move,
|
||||||
|
A::Up => SampleAction::Up,
|
||||||
|
A::Cancel => SampleAction::Cancel,
|
||||||
|
},
|
||||||
|
x: s.x,
|
||||||
|
y: s.y,
|
||||||
|
pressure: s.pressure,
|
||||||
|
eraser: s.eraser,
|
||||||
|
buttons: s.buttons,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two pen buttons of Android's `MotionEvent` (`BUTTON_STYLUS_PRIMARY`/`SECONDARY`).
|
||||||
|
const ANDROID_BUTTONS: [(u32, &str); 2] = [
|
||||||
|
(0x20, "android-stylus-primary"),
|
||||||
|
(0x40, "android-stylus-secondary"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// How long the pen may be out of sight before it counts as gone. Android reports
|
||||||
|
/// `HoverExit` right before the tip touches down, and pens without hover report nothing
|
||||||
|
/// after lifting.
|
||||||
|
const ANDROID_OUT_DELAY: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
|
/// Turns Android pen samples into [`PenInput`]s.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct AndroidPen {
|
||||||
|
in_range: bool,
|
||||||
|
down: bool,
|
||||||
|
eraser: bool,
|
||||||
|
buttons: u32,
|
||||||
|
/// Since when the pen is neither hovering nor touching
|
||||||
|
gone_since: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidPen {
|
||||||
|
/// The single pen Android reports
|
||||||
|
const TOOL: u64 = 1;
|
||||||
|
|
||||||
|
fn push(&self, out: &mut Vec<PenInput>, event: PenEvent) {
|
||||||
|
out.push(PenInput {
|
||||||
|
tool: Self::TOOL,
|
||||||
|
is_eraser: self.eraser,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events for `samples` (in this order); `scale` converts physical to logical pixels.
|
||||||
|
pub fn convert(&mut self, samples: &[Sample], scale: f32, now: Instant) -> Vec<PenInput> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for sample in samples {
|
||||||
|
self.eraser = sample.eraser;
|
||||||
|
for (mask, name) in ANDROID_BUTTONS {
|
||||||
|
let pressed = sample.buttons & mask != 0;
|
||||||
|
if pressed != (self.buttons & mask != 0) {
|
||||||
|
self.push(
|
||||||
|
&mut out,
|
||||||
|
PenEvent::Button {
|
||||||
|
key: hash_of(name),
|
||||||
|
pressed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.buttons = sample.buttons;
|
||||||
|
|
||||||
|
let position = [sample.x / scale, sample.y / scale];
|
||||||
|
let touching = Some(sample.pressure.clamp(0.0, 1.0));
|
||||||
|
match sample.action {
|
||||||
|
SampleAction::HoverEnter | SampleAction::HoverMove => {
|
||||||
|
self.gone_since = None;
|
||||||
|
self.enter(&mut out);
|
||||||
|
let pose = PenEvent::Pose {
|
||||||
|
position,
|
||||||
|
pressure: None,
|
||||||
|
};
|
||||||
|
self.push(&mut out, pose);
|
||||||
|
}
|
||||||
|
SampleAction::HoverExit => {
|
||||||
|
if !self.down {
|
||||||
|
self.gone_since.get_or_insert(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SampleAction::Down => {
|
||||||
|
self.gone_since = None;
|
||||||
|
self.enter(&mut out);
|
||||||
|
let pose = PenEvent::Pose {
|
||||||
|
position,
|
||||||
|
pressure: touching,
|
||||||
|
};
|
||||||
|
self.push(&mut out, pose);
|
||||||
|
if !self.down {
|
||||||
|
self.down = true;
|
||||||
|
self.push(&mut out, PenEvent::Down);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SampleAction::Move => {
|
||||||
|
self.gone_since = None;
|
||||||
|
self.enter(&mut out);
|
||||||
|
let pressure = if self.down { touching } else { None };
|
||||||
|
self.push(&mut out, PenEvent::Pose { position, pressure });
|
||||||
|
}
|
||||||
|
SampleAction::Up | SampleAction::Cancel => {
|
||||||
|
if self.down {
|
||||||
|
if sample.action == SampleAction::Up {
|
||||||
|
let pose = PenEvent::Pose {
|
||||||
|
position,
|
||||||
|
pressure: touching,
|
||||||
|
};
|
||||||
|
self.push(&mut out, pose);
|
||||||
|
}
|
||||||
|
self.down = false;
|
||||||
|
self.push(&mut out, PenEvent::Up);
|
||||||
|
}
|
||||||
|
self.gone_since = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.extend(self.poll(now));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter(&mut self, out: &mut Vec<PenInput>) {
|
||||||
|
if !self.in_range {
|
||||||
|
self.in_range = true;
|
||||||
|
self.push(out, PenEvent::In);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Out` once the pen has been gone for a moment; call regularly while it is in range.
|
||||||
|
pub fn poll(&mut self, now: Instant) -> Vec<PenInput> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let gone = self
|
||||||
|
.gone_since
|
||||||
|
.is_some_and(|since| now.duration_since(since) >= ANDROID_OUT_DELAY);
|
||||||
|
if gone && self.in_range && !self.down {
|
||||||
|
self.in_range = false;
|
||||||
|
self.gone_since = None;
|
||||||
|
if self.buttons != 0 {
|
||||||
|
for (mask, name) in ANDROID_BUTTONS {
|
||||||
|
if self.buttons & mask != 0 {
|
||||||
|
self.push(
|
||||||
|
&mut out,
|
||||||
|
PenEvent::Button {
|
||||||
|
key: hash_of(name),
|
||||||
|
pressed: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.buttons = 0;
|
||||||
|
}
|
||||||
|
self.push(&mut out, PenEvent::Out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample(action: SampleAction, x: f32, pressure: f32, buttons: u32) -> Sample {
|
||||||
|
Sample {
|
||||||
|
action,
|
||||||
|
x,
|
||||||
|
y: 10.0,
|
||||||
|
pressure,
|
||||||
|
eraser: false,
|
||||||
|
buttons,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kinds(inputs: &[PenInput]) -> Vec<&'static str> {
|
||||||
|
inputs
|
||||||
|
.iter()
|
||||||
|
.map(|i| match i.event {
|
||||||
|
PenEvent::In => "in",
|
||||||
|
PenEvent::Out => "out",
|
||||||
|
PenEvent::Down => "down",
|
||||||
|
PenEvent::Up => "up",
|
||||||
|
PenEvent::Pose { pressure: None, .. } => "hover",
|
||||||
|
PenEvent::Pose { .. } => "pose",
|
||||||
|
PenEvent::Button { pressed: true, .. } => "button-down",
|
||||||
|
PenEvent::Button { .. } => "button-up",
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_android_stroke_with_hover() {
|
||||||
|
use SampleAction::*;
|
||||||
|
let mut pen = AndroidPen::default();
|
||||||
|
let t = Instant::now();
|
||||||
|
let ms = |n| t + Duration::from_millis(n);
|
||||||
|
|
||||||
|
let out = pen.convert(&[sample(HoverEnter, 20.0, 0.0, 0)], 2.0, t);
|
||||||
|
assert_eq!(kinds(&out), ["in", "hover"]);
|
||||||
|
assert_eq!(
|
||||||
|
out[1].event,
|
||||||
|
PenEvent::Pose {
|
||||||
|
position: [10.0, 5.0],
|
||||||
|
pressure: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Android ends the hover right before the tip touches: no Out in between
|
||||||
|
let out = pen.convert(&[sample(HoverExit, 20.0, 0.0, 0)], 2.0, ms(5));
|
||||||
|
assert!(out.is_empty());
|
||||||
|
let out = pen.convert(
|
||||||
|
&[sample(Down, 20.0, 0.4, 0), sample(Move, 30.0, 0.5, 0)],
|
||||||
|
2.0,
|
||||||
|
ms(10),
|
||||||
|
);
|
||||||
|
assert_eq!(kinds(&out), ["pose", "down", "pose"]);
|
||||||
|
assert!(pen.poll(ms(500)).is_empty(), "still touching");
|
||||||
|
|
||||||
|
let out = pen.convert(&[sample(Up, 30.0, 0.0, 0)], 2.0, ms(600));
|
||||||
|
assert_eq!(kinds(&out), ["pose", "up"]);
|
||||||
|
let out = pen.convert(&[sample(HoverEnter, 30.0, 0.0, 0)], 2.0, ms(610));
|
||||||
|
assert_eq!(kinds(&out), ["hover"]);
|
||||||
|
|
||||||
|
pen.convert(&[sample(HoverExit, 30.0, 0.0, 0)], 2.0, ms(700));
|
||||||
|
assert!(pen.poll(ms(750)).is_empty());
|
||||||
|
assert_eq!(kinds(&pen.poll(ms(900))), ["out"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_android_pen_without_hover_leaves_after_lifting() {
|
||||||
|
use SampleAction::*;
|
||||||
|
let mut pen = AndroidPen::default();
|
||||||
|
let t = Instant::now();
|
||||||
|
let out = pen.convert(&[sample(Down, 0.0, 0.7, 0)], 1.0, t);
|
||||||
|
assert_eq!(kinds(&out), ["in", "pose", "down"]);
|
||||||
|
pen.convert(&[sample(Up, 0.0, 0.0, 0)], 1.0, t);
|
||||||
|
assert_eq!(kinds(&pen.poll(t + Duration::from_millis(200))), ["out"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_android_buttons_and_cancel() {
|
||||||
|
use SampleAction::*;
|
||||||
|
let mut pen = AndroidPen::default();
|
||||||
|
let t = Instant::now();
|
||||||
|
let out = pen.convert(
|
||||||
|
&[
|
||||||
|
sample(HoverMove, 0.0, 0.0, 0x20),
|
||||||
|
sample(Down, 0.0, 0.5, 0x20),
|
||||||
|
sample(Cancel, 0.0, 0.0, 0x20),
|
||||||
|
],
|
||||||
|
1.0,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds(&out),
|
||||||
|
["button-down", "in", "hover", "pose", "down", "up"]
|
||||||
|
);
|
||||||
|
// Pen gone with the button still held: released before Out
|
||||||
|
let out = pen.poll(t + Duration::from_millis(200));
|
||||||
|
assert_eq!(kinds(&out), ["button-up", "out"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
@@ -127,6 +127,147 @@ where
|
|||||||
event_loop.run(handler)
|
event_loop.run(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Android activity, for calls winit doesn't offer (soft keyboard, Issue #42).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
static ANDROID_APP: std::sync::OnceLock<winit::platform::android::activity::AndroidApp> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Remembers the activity; called once by `android_main`.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn set_android_app(app: winit::platform::android::activity::AndroidApp) {
|
||||||
|
let _ = ANDROID_APP.set(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn android_app() -> Option<winit::platform::android::activity::AndroidApp> {
|
||||||
|
ANDROID_APP.get().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn publish_export(path: std::path::PathBuf) -> Result<std::path::PathBuf, String> {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
match android_files::publish_to_downloads(&path) {
|
||||||
|
Ok(location) => {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
return Ok(location);
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("export not copied to Downloads: {err}"),
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android_files {
|
||||||
|
use jni::objects::{JObject, JValue};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Folder below the shared storage the exports go to
|
||||||
|
const RELATIVE_PATH: &str = "Download/ink-notes";
|
||||||
|
|
||||||
|
fn mime_type(path: &Path) -> &'static str {
|
||||||
|
match path.extension().and_then(|e| e.to_str()) {
|
||||||
|
Some("pdf") => "application/pdf",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish_to_downloads(path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let app = super::android_app().ok_or("no activity")?;
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or("invalid file name")?
|
||||||
|
.to_string();
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
// Safety: pointers to the VM and the activity (a global reference) that live as long
|
||||||
|
// as the app
|
||||||
|
let vm =
|
||||||
|
unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
|
||||||
|
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||||
|
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
|
||||||
|
let result = insert(&mut env, &activity, &name, mime_type(path), &bytes);
|
||||||
|
if env.exception_check().unwrap_or(false) {
|
||||||
|
let _ = env.exception_describe();
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
}
|
||||||
|
result.map_err(|e| e.to_string())?;
|
||||||
|
Ok(Path::new(RELATIVE_PATH).join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(
|
||||||
|
env: &mut JNIEnv,
|
||||||
|
activity: &JObject,
|
||||||
|
name: &str,
|
||||||
|
mime: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> jni::errors::Result<()> {
|
||||||
|
let sdk = env
|
||||||
|
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")?
|
||||||
|
.i()?;
|
||||||
|
if sdk < 29 {
|
||||||
|
return Err(jni::errors::Error::JniCall(jni::errors::JniError::Unknown));
|
||||||
|
}
|
||||||
|
let values = env.new_object("android/content/ContentValues", "()V", &[])?;
|
||||||
|
for (key, value) in [
|
||||||
|
("_display_name", name),
|
||||||
|
("mime_type", mime),
|
||||||
|
("relative_path", RELATIVE_PATH),
|
||||||
|
] {
|
||||||
|
let key = env.new_string(key)?;
|
||||||
|
let value = env.new_string(value)?;
|
||||||
|
env.call_method(
|
||||||
|
&values,
|
||||||
|
"put",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(&key), JValue::Object(&value)],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let resolver = env
|
||||||
|
.call_method(
|
||||||
|
activity,
|
||||||
|
"getContentResolver",
|
||||||
|
"()Landroid/content/ContentResolver;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let collection = env
|
||||||
|
.get_static_field(
|
||||||
|
"android/provider/MediaStore$Downloads",
|
||||||
|
"EXTERNAL_CONTENT_URI",
|
||||||
|
"Landroid/net/Uri;",
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let uri = env
|
||||||
|
.call_method(
|
||||||
|
&resolver,
|
||||||
|
"insert",
|
||||||
|
"(Landroid/net/Uri;Landroid/content/ContentValues;)Landroid/net/Uri;",
|
||||||
|
&[JValue::Object(&collection), JValue::Object(&values)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
if uri.is_null() {
|
||||||
|
return Err(jni::errors::Error::NullPtr("MediaStore insert"));
|
||||||
|
}
|
||||||
|
let stream = env
|
||||||
|
.call_method(
|
||||||
|
&resolver,
|
||||||
|
"openOutputStream",
|
||||||
|
"(Landroid/net/Uri;)Ljava/io/OutputStream;",
|
||||||
|
&[JValue::Object(&uri)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let array = env.byte_array_from_slice(bytes)?;
|
||||||
|
env.call_method(&stream, "write", "([B)V", &[JValue::Object(&array)])?;
|
||||||
|
env.call_method(&stream, "close", "()V", &[])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Gives the window the app id (Wayland: app_id, X11: WM_CLASS), so that dock and task bar
|
/// Gives the window the app id (Wayland: app_id, X11: WM_CLASS), so that dock and task bar
|
||||||
/// show the app icon for it.
|
/// show the app icon for it.
|
||||||
pub fn with_app_id(
|
pub fn with_app_id(
|
||||||
|
|||||||
Vendored
+35
@@ -77,6 +77,41 @@ impl<T> EventLoopBuilderExtAndroid for EventLoopBuilder<T> {
|
|||||||
/// depending on the `android_activity` crate, and instead consume the API that
|
/// depending on the `android_activity` crate, and instead consume the API that
|
||||||
/// is re-exported by Winit.
|
/// is re-exported by Winit.
|
||||||
///
|
///
|
||||||
|
/// ink-notes patch: what a pen sample reports (Issue #44).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PenAction {
|
||||||
|
/// Pen comes near the screen, moves above it or leaves it
|
||||||
|
HoverEnter,
|
||||||
|
HoverMove,
|
||||||
|
HoverExit,
|
||||||
|
/// Pen touches the screen, moves on it, is lifted or the gesture is cancelled
|
||||||
|
Down,
|
||||||
|
Move,
|
||||||
|
Up,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: one sample of a pen (tool type stylus or eraser). Such pointers are not
|
||||||
|
/// reported as `WindowEvent::Touch`; the app collects them with [`take_pen_samples`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PenSample {
|
||||||
|
pub action: PenAction,
|
||||||
|
/// Position in physical pixels
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
/// Usually 0..=1
|
||||||
|
pub pressure: f32,
|
||||||
|
/// Eraser end of the pen (tool type eraser)
|
||||||
|
pub eraser: bool,
|
||||||
|
/// `MotionEvent.getButtonState()`; the pen buttons are 0x20 (primary) and 0x40 (secondary)
|
||||||
|
pub buttons: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: the pen samples received since the last call, oldest first.
|
||||||
|
pub fn take_pen_samples() -> Vec<PenSample> {
|
||||||
|
crate::platform_impl::take_pen_samples()
|
||||||
|
}
|
||||||
|
|
||||||
/// For compatibility applications should then import the `AndroidApp` type for
|
/// For compatibility applications should then import the `AndroidApp` type for
|
||||||
/// their `android_main(app: AndroidApp)` function like:
|
/// their `android_main(app: AndroidApp)` function like:
|
||||||
/// ```rust
|
/// ```rust
|
||||||
|
|||||||
+68
-2
@@ -11,7 +11,7 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction};
|
use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction, ToolType};
|
||||||
use android_activity::{
|
use android_activity::{
|
||||||
AndroidApp, AndroidAppWaker, ConfigurationRef, InputStatus, MainEvent, Rect,
|
AndroidApp, AndroidAppWaker, ConfigurationRef, InputStatus, MainEvent, Rect,
|
||||||
};
|
};
|
||||||
@@ -382,6 +382,7 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
InputEvent::MotionEvent(motion_event) => {
|
InputEvent::MotionEvent(motion_event) => {
|
||||||
let window_id = window::WindowId(WindowId);
|
let window_id = window::WindowId(WindowId);
|
||||||
let device_id = event::DeviceId(DeviceId(motion_event.device_id()));
|
let device_id = event::DeviceId(DeviceId(motion_event.device_id()));
|
||||||
|
record_pen_samples(motion_event);
|
||||||
|
|
||||||
let phase = match motion_event.action() {
|
let phase = match motion_event.action() {
|
||||||
MotionAction::Down | MotionAction::PointerDown => {
|
MotionAction::Down | MotionAction::PointerDown => {
|
||||||
@@ -408,6 +409,10 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for pointer in pointers {
|
for pointer in pointers {
|
||||||
|
// ink-notes patch: pens are reported through `take_pen_samples`
|
||||||
|
if is_pen(&pointer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let location = PhysicalPosition {
|
let location = PhysicalPosition {
|
||||||
x: pointer.x() as _,
|
x: pointer.x() as _,
|
||||||
y: pointer.y() as _,
|
y: pointer.y() as _,
|
||||||
@@ -450,6 +455,15 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
&mut self.combining_accent,
|
&mut self.combining_accent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ink-notes patch: the character typed, so that text fields receive it
|
||||||
|
let text = match (state, &key_char) {
|
||||||
|
(
|
||||||
|
event::ElementState::Pressed,
|
||||||
|
Some(android_activity::input::KeyMapChar::Unicode(c)),
|
||||||
|
) if !c.is_control() => Some(smol_str::SmolStr::from_iter([*c])),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
let event = event::Event::WindowEvent {
|
let event = event::Event::WindowEvent {
|
||||||
window_id: window::WindowId(WindowId),
|
window_id: window::WindowId(WindowId),
|
||||||
event: event::WindowEvent::KeyboardInput {
|
event: event::WindowEvent::KeyboardInput {
|
||||||
@@ -460,7 +474,7 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
logical_key: keycodes::to_logical(key_char, keycode),
|
logical_key: keycodes::to_logical(key_char, keycode),
|
||||||
location: keycodes::to_location(keycode),
|
location: keycodes::to_location(keycode),
|
||||||
repeat: key.repeat_count() > 0,
|
repeat: key.repeat_count() > 0,
|
||||||
text: None,
|
text,
|
||||||
platform_specific: KeyEventExtra {},
|
platform_specific: KeyEventExtra {},
|
||||||
},
|
},
|
||||||
is_synthetic: false,
|
is_synthetic: false,
|
||||||
@@ -1124,3 +1138,55 @@ impl VideoMode {
|
|||||||
self.monitor.clone()
|
self.monitor.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: pen samples waiting for `take_pen_samples` (Issue #44)
|
||||||
|
static PEN_SAMPLES: Mutex<Vec<crate::platform::android::PenSample>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
fn is_pen(pointer: &android_activity::input::Pointer<'_>) -> bool {
|
||||||
|
matches!(pointer.tool_type(), ToolType::Stylus | ToolType::Eraser)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_pen_samples(motion_event: &android_activity::input::MotionEvent<'_>) {
|
||||||
|
use crate::platform::android::{PenAction, PenSample};
|
||||||
|
|
||||||
|
let action = match motion_event.action() {
|
||||||
|
MotionAction::Down | MotionAction::PointerDown => PenAction::Down,
|
||||||
|
MotionAction::Up | MotionAction::PointerUp => PenAction::Up,
|
||||||
|
MotionAction::Move => PenAction::Move,
|
||||||
|
MotionAction::Cancel => PenAction::Cancel,
|
||||||
|
MotionAction::HoverEnter => PenAction::HoverEnter,
|
||||||
|
MotionAction::HoverMove => PenAction::HoverMove,
|
||||||
|
MotionAction::HoverExit => PenAction::HoverExit,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let buttons = motion_event.button_state().0;
|
||||||
|
let mut record = |pointer: android_activity::input::Pointer<'_>| {
|
||||||
|
if !is_pen(&pointer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sample = PenSample {
|
||||||
|
action,
|
||||||
|
x: pointer.x(),
|
||||||
|
y: pointer.y(),
|
||||||
|
pressure: pointer.pressure(),
|
||||||
|
eraser: pointer.tool_type() == ToolType::Eraser,
|
||||||
|
buttons,
|
||||||
|
};
|
||||||
|
let mut samples = PEN_SAMPLES.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Nobody collects them (app without pen support): don't grow without bound
|
||||||
|
if samples.len() >= 4096 {
|
||||||
|
samples.remove(0);
|
||||||
|
}
|
||||||
|
samples.push(sample);
|
||||||
|
};
|
||||||
|
match action {
|
||||||
|
PenAction::Down | PenAction::Up => {
|
||||||
|
record(motion_event.pointer_at_index(motion_event.pointer_index()))
|
||||||
|
}
|
||||||
|
_ => motion_event.pointers().for_each(record),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_pen_samples() -> Vec<crate::platform::android::PenSample> {
|
||||||
|
std::mem::take(&mut *PEN_SAMPLES.lock().unwrap_or_else(|e| e.into_inner()))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user