Move IME candidate window following text cursor (#258)
* egui_web: enable IME support on web. * Move candidate window following text cursor. * Preclude too frequent agent movement. * IME candidate window move on native app.
This commit is contained in:
parent
1c60dc8d66
commit
22cd1a8e10
6 changed files with 74 additions and 11 deletions
|
@ -23,6 +23,9 @@ pub struct Output {
|
||||||
|
|
||||||
/// Events that may be useful to e.g. a screen reader.
|
/// Events that may be useful to e.g. a screen reader.
|
||||||
pub events: Vec<OutputEvent>,
|
pub events: Vec<OutputEvent>,
|
||||||
|
|
||||||
|
/// Position of text widgts' cursor
|
||||||
|
pub text_cursor: Option<crate::Pos2>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Output {
|
impl Output {
|
||||||
|
|
|
@ -382,6 +382,7 @@ impl<'t> TextEdit<'t> {
|
||||||
ui.output().cursor_icon = CursorIcon::Text;
|
ui.output().cursor_icon = CursorIcon::Text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut text_cursor = None;
|
||||||
if ui.memory().has_focus(id) && enabled {
|
if ui.memory().has_focus(id) && enabled {
|
||||||
let mut cursorp = state
|
let mut cursorp = state
|
||||||
.cursorp
|
.cursorp
|
||||||
|
@ -495,6 +496,7 @@ impl<'t> TextEdit<'t> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
text_cursor = Some(cursorp);
|
||||||
state.cursorp = Some(cursorp);
|
state.cursorp = Some(cursorp);
|
||||||
|
|
||||||
state
|
state
|
||||||
|
@ -503,6 +505,15 @@ impl<'t> TextEdit<'t> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ui.memory().has_focus(id) {
|
if ui.memory().has_focus(id) {
|
||||||
|
{
|
||||||
|
let mut output = ui.ctx().output();
|
||||||
|
output.text_cursor = text_cursor.map(|c| {
|
||||||
|
galley
|
||||||
|
.pos_from_cursor(&c.primary)
|
||||||
|
.translate(response.rect.min.to_vec2())
|
||||||
|
.left_top()
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(cursorp) = state.cursorp {
|
if let Some(cursorp) = state.cursorp {
|
||||||
paint_cursor_selection(ui, response.rect.min, &galley, &cursorp);
|
paint_cursor_selection(ui, response.rect.min, &galley, &cursorp);
|
||||||
paint_cursor_end(ui, response.rect.min, &galley, &cursorp.primary);
|
paint_cursor_end(ui, response.rect.min, &galley, &cursorp.primary);
|
||||||
|
|
|
@ -212,7 +212,7 @@ pub fn run(mut app: Box<dyn epi::App>) -> ! {
|
||||||
|
|
||||||
set_cursor_icon(&display, egui_output.cursor_icon);
|
set_cursor_icon(&display, egui_output.cursor_icon);
|
||||||
current_cursor_icon = egui_output.cursor_icon;
|
current_cursor_icon = egui_output.cursor_icon;
|
||||||
handle_output(egui_output, clipboard.as_mut());
|
handle_output(egui_output, clipboard.as_mut(), &display);
|
||||||
|
|
||||||
// TODO: handle app_output
|
// TODO: handle app_output
|
||||||
// eprintln!("Warmed up in {} ms", warm_up_start.elapsed().as_millis())
|
// eprintln!("Warmed up in {} ms", warm_up_start.elapsed().as_millis())
|
||||||
|
@ -289,7 +289,7 @@ pub fn run(mut app: Box<dyn epi::App>) -> ! {
|
||||||
set_cursor_icon(&display, egui_output.cursor_icon);
|
set_cursor_icon(&display, egui_output.cursor_icon);
|
||||||
current_cursor_icon = egui_output.cursor_icon;
|
current_cursor_icon = egui_output.cursor_icon;
|
||||||
}
|
}
|
||||||
handle_output(egui_output, clipboard.as_mut());
|
handle_output(egui_output, clipboard.as_mut(), &display);
|
||||||
|
|
||||||
#[cfg(feature = "persistence")]
|
#[cfg(feature = "persistence")]
|
||||||
if let Some(storage) = &mut storage {
|
if let Some(storage) = &mut storage {
|
||||||
|
|
|
@ -303,7 +303,11 @@ fn set_cursor_icon(display: &glium::backend::glutin::Display, cursor_icon: egui:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_output(output: egui::Output, clipboard: Option<&mut ClipboardContext>) {
|
pub fn handle_output(
|
||||||
|
output: egui::Output,
|
||||||
|
clipboard: Option<&mut ClipboardContext>,
|
||||||
|
display: &glium::Display,
|
||||||
|
) {
|
||||||
if let Some(open) = output.open_url {
|
if let Some(open) = output.open_url {
|
||||||
if let Err(err) = webbrowser::open(&open.url) {
|
if let Err(err) = webbrowser::open(&open.url) {
|
||||||
eprintln!("Failed to open url: {}", err);
|
eprintln!("Failed to open url: {}", err);
|
||||||
|
@ -317,6 +321,13 @@ pub fn handle_output(output: egui::Output, clipboard: Option<&mut ClipboardConte
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(egui::Pos2 { x, y }) = output.text_cursor {
|
||||||
|
display
|
||||||
|
.gl_window()
|
||||||
|
.window()
|
||||||
|
.set_ime_position(glium::glutin::dpi::LogicalPosition { x, y })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_clipboard() -> Option<ClipboardContext> {
|
pub fn init_clipboard() -> Option<ClipboardContext> {
|
||||||
|
|
|
@ -138,6 +138,7 @@ pub struct AppRunner {
|
||||||
screen_reader: crate::screen_reader::ScreenReader,
|
screen_reader: crate::screen_reader::ScreenReader,
|
||||||
#[cfg(feature = "http")]
|
#[cfg(feature = "http")]
|
||||||
http: Arc<http::WebHttp>,
|
http: Arc<http::WebHttp>,
|
||||||
|
pub(crate) text_cursor: Option<egui::Pos2>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppRunner {
|
impl AppRunner {
|
||||||
|
@ -156,6 +157,7 @@ impl AppRunner {
|
||||||
screen_reader: Default::default(),
|
screen_reader: Default::default(),
|
||||||
#[cfg(feature = "http")]
|
#[cfg(feature = "http")]
|
||||||
http: Arc::new(http::WebHttp {}),
|
http: Arc::new(http::WebHttp {}),
|
||||||
|
text_cursor: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,7 +224,7 @@ impl AppRunner {
|
||||||
if self.web_backend.ctx.memory().options.screen_reader {
|
if self.web_backend.ctx.memory().options.screen_reader {
|
||||||
self.screen_reader.speak(&egui_output.events_description());
|
self.screen_reader.speak(&egui_output.events_description());
|
||||||
}
|
}
|
||||||
handle_output(&egui_output);
|
handle_output(&egui_output, self);
|
||||||
|
|
||||||
{
|
{
|
||||||
let epi::backend::AppOutput {
|
let epi::backend::AppOutput {
|
||||||
|
|
|
@ -28,7 +28,7 @@ use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
static AGENT_ID: &str = "text_agent";
|
static AGENT_ID: &str = "egui_text_agent";
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Helpers to hide some of the verbosity of web_sys
|
// Helpers to hide some of the verbosity of web_sys
|
||||||
|
@ -234,13 +234,14 @@ impl epi::Storage for LocalStorage {
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn handle_output(output: &egui::Output) {
|
pub fn handle_output(output: &egui::Output, runner: &mut AppRunner) {
|
||||||
let egui::Output {
|
let egui::Output {
|
||||||
cursor_icon,
|
cursor_icon,
|
||||||
open_url,
|
open_url,
|
||||||
copied_text,
|
copied_text,
|
||||||
needs_repaint: _, // handled elsewhere
|
needs_repaint: _, // handled elsewhere
|
||||||
events: _, // we ignore these (TODO: accessibility screen reader)
|
events: _, // we ignore these (TODO: accessibility screen reader)
|
||||||
|
text_cursor: cursor,
|
||||||
} = output;
|
} = output;
|
||||||
|
|
||||||
set_cursor_icon(*cursor_icon);
|
set_cursor_icon(*cursor_icon);
|
||||||
|
@ -255,6 +256,11 @@ pub fn handle_output(output: &egui::Output) {
|
||||||
|
|
||||||
#[cfg(not(web_sys_unstable_apis))]
|
#[cfg(not(web_sys_unstable_apis))]
|
||||||
let _ = copied_text;
|
let _ = copied_text;
|
||||||
|
|
||||||
|
if &runner.text_cursor != cursor {
|
||||||
|
move_text_cursor(cursor, runner.canvas_id());
|
||||||
|
runner.text_cursor = *cursor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_cursor_icon(cursor: egui::CursorIcon) -> Option<()> {
|
pub fn set_cursor_icon(cursor: egui::CursorIcon) -> Option<()> {
|
||||||
|
@ -464,7 +470,7 @@ fn paint_and_schedule(runner_ref: AppRunnerRef) -> Result<(), JsValue> {
|
||||||
request_animation_frame(runner_ref)
|
request_animation_frame(runner_ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text_agent_hidden() -> bool {
|
fn text_agent() -> web_sys::HtmlInputElement {
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
web_sys::window()
|
web_sys::window()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
@ -472,9 +478,8 @@ fn text_agent_hidden() -> bool {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_element_by_id(AGENT_ID)
|
.get_element_by_id(AGENT_ID)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.dyn_into::<web_sys::HtmlInputElement>()
|
.dyn_into()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.hidden()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
|
fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
|
||||||
|
@ -508,7 +513,7 @@ fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
|
||||||
&& !modifiers.command
|
&& !modifiers.command
|
||||||
&& !should_ignore_key(&key)
|
&& !should_ignore_key(&key)
|
||||||
// When text agent is shown, it sends text event instead.
|
// When text agent is shown, it sends text event instead.
|
||||||
&& text_agent_hidden()
|
&& text_agent().hidden()
|
||||||
{
|
{
|
||||||
runner_lock.input.raw.events.push(egui::Event::Text(key));
|
runner_lock.input.raw.events.push(egui::Event::Text(key));
|
||||||
}
|
}
|
||||||
|
@ -703,7 +708,6 @@ fn install_text_agent(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
|
||||||
let input_clone = input.clone();
|
let input_clone = input.clone();
|
||||||
let runner_ref = runner_ref.clone();
|
let runner_ref = runner_ref.clone();
|
||||||
let on_compositionend = Closure::wrap(Box::new(move |event: web_sys::CompositionEvent| {
|
let on_compositionend = Closure::wrap(Box::new(move |event: web_sys::CompositionEvent| {
|
||||||
// let event_type = event.type_();
|
|
||||||
match event.type_().as_ref() {
|
match event.type_().as_ref() {
|
||||||
"compositionstart" => {
|
"compositionstart" => {
|
||||||
is_composing.set(true);
|
is_composing.set(true);
|
||||||
|
@ -995,3 +999,35 @@ fn manipulate_agent(canvas_id: &str, latest_cursor: Option<egui::Pos2>) -> Optio
|
||||||
}
|
}
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"];
|
||||||
|
/// If context is running under mobile device?
|
||||||
|
fn is_mobile() -> Option<bool> {
|
||||||
|
let user_agent = web_sys::window()?.navigator().user_agent().ok()?;
|
||||||
|
let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name));
|
||||||
|
Some(is_mobile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move angnt to text cursor's position, on desktop/laptop, candidate window moves following text elemt(agent),
|
||||||
|
// so it appears that the IME candidate window moves with text cursor.
|
||||||
|
// On mobile devices, there is no need to do that.
|
||||||
|
fn move_text_cursor(cursor: &Option<egui::Pos2>, canvas_id: &str) -> Option<()> {
|
||||||
|
let style = text_agent().style();
|
||||||
|
// Note: movint agent on mobile devices will lead to unpreditable scroll.
|
||||||
|
if is_mobile() == Some(false) {
|
||||||
|
cursor.as_ref().and_then(|&egui::Pos2 { x, y }| {
|
||||||
|
let canvas = canvas_element(canvas_id)?;
|
||||||
|
let y = y + (canvas.scroll_top() + canvas.offset_top()) as f32;
|
||||||
|
let x = x + (canvas.scroll_left() + canvas.offset_left()) as f32;
|
||||||
|
// Canvas is translated 50% horizontally in html.
|
||||||
|
let x = x - canvas.offset_width() as f32 / 2.0;
|
||||||
|
style.set_property("position", "absolute").ok()?;
|
||||||
|
style.set_property("top", &(y.to_string() + "px")).ok()?;
|
||||||
|
style.set_property("left", &(x.to_string() + "px")).ok()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
style.set_property("position", "absolute").ok()?;
|
||||||
|
style.set_property("top", "0px").ok()?;
|
||||||
|
style.set_property("left", "0px").ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue