diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad280b6bc..5f1d43014e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ You can find its changes [documented below](#070---2021-01-01). # Unreleased ### Highlights +- International text input support (IME) on macOS. ### Added - Add `scroll()` method in WidgetExt ([#1600] by [@totsteps]) @@ -22,6 +23,7 @@ You can find its changes [documented below](#070---2021-01-01). - Shell: windows implementation from content_insets ([#1592] by [@HoNile]) - Shell: IME API and macOS IME implementation ([#1619] by [@lord]) - Scroll::content_must_fill and a few other new Scroll methods ([#1635] by [@cmyr]) +- New `TextBox` widget with IME integration ([#1636] by [@cmyr]) - Added ListIter implementations for OrdMap ([#1641] by [@Lejero]) ### Changed @@ -635,6 +637,7 @@ Last release without a changelog :( [#1619]: https://github.com/linebender/druid/pull/1619 [#1634]: https://github.com/linebender/druid/pull/1634 [#1635]: https://github.com/linebender/druid/pull/1635 +[#1636]: https://github.com/linebender/druid/pull/1636 [#1641]: https://github.com/linebender/druid/pull/1641 [#1647]: https://github.com/linebender/druid/pull/1647 diff --git a/druid/examples/web/src/lib.rs b/druid/examples/web/src/lib.rs index f83c467ec9..87c50a4d87 100644 --- a/druid/examples/web/src/lib.rs +++ b/druid/examples/web/src/lib.rs @@ -79,6 +79,7 @@ impl_example!(styled_text.unwrap()); impl_example!(switches); impl_example!(timer); impl_example!(tabs); +impl_example!(textbox); impl_example!(transparency); impl_example!(view_switcher); impl_example!(widget_gallery); diff --git a/druid/src/contexts.rs b/druid/src/contexts.rs index 4d2f485777..ffaee23938 100644 --- a/druid/src/contexts.rs +++ b/druid/src/contexts.rs @@ -26,6 +26,7 @@ use tracing::{error, trace, warn}; use crate::core::{CommandQueue, CursorChange, FocusChange, WidgetState}; use crate::env::KeyLike; use crate::piet::{Piet, PietText, RenderContext}; +use crate::shell::text::Event as ImeInvalidation; use crate::shell::Region; use crate::text::{ImeHandlerRef, TextFieldRegistration}; use crate::{ @@ -389,10 +390,10 @@ impl_context_method!(EventCtx<'_, '_>, UpdateCtx<'_, '_>, LifeCycleCtx<'_, '_>, /// A widget that accepts text input should call this anytime input state /// (such as the text or the selection) changes as a result of a non text-input /// event. - pub fn invalidate_text_input(&mut self, event: Option) { + pub fn invalidate_text_input(&mut self, event: ImeInvalidation) { let payload = commands::ImeInvalidation { widget: self.widget_id(), - event: event.unwrap_or(crate::shell::text::Event::Reset), + event, }; let cmd = commands::INVALIDATE_IME .with(payload) diff --git a/druid/src/text/input_component.rs b/druid/src/text/input_component.rs index 568120e804..2798f4864b 100644 --- a/druid/src/text/input_component.rs +++ b/druid/src/text/input_component.rs @@ -58,9 +58,11 @@ pub struct TextComponent { lock: Arc>, } -/// The inner state of an `EditSession`. +/// Editable text state. /// -/// This may be modified directly, or it may be modified by the platform. +/// This is the inner state of a [`TextComponent`]. It should only be accessed +/// through its containing [`TextComponent`], or by the platform through an +/// [`ImeHandlerRef`] created by [`TextComponent::input_handler`]. #[derive(Debug, Clone)] pub struct EditSession { /// The inner [`TextLayout`] object. @@ -149,8 +151,10 @@ impl ImeHandlerRef for EditSessionRef { } impl TextComponent<()> { - /// If the payload is true, this follows an edit, and the view will need to be laid - /// out before scrolling. + /// A notification sent by the component when the cursor has moved. + /// + /// If the payload is true, this follows an edit, and the view will need + /// layout before scrolling. pub const SCROLL_TO: Selector = Selector::new("druid-builtin.textbox-scroll-to"); /// A notification sent by the component when the user hits return. @@ -334,7 +338,7 @@ impl Widget for TextComponent { let new_origin = ctx.window_origin(); if prev_origin != new_origin { self.borrow_mut().origin = ctx.window_origin(); - ctx.invalidate_text_input(Some(ImeUpdate::LayoutChanged)); + ctx.invalidate_text_input(ImeUpdate::LayoutChanged); } } } diff --git a/druid/src/text/movement.rs b/druid/src/text/movement.rs index d88131628e..54aac6a3f1 100644 --- a/druid/src/text/movement.rs +++ b/druid/src/text/movement.rs @@ -43,6 +43,7 @@ pub enum Movement { EndOfDocument, } +//FIXME: we should remove this whole file, and use the Movement type defined in druid-shell? impl From for Movement { fn from(src: crate::shell::text::Movement) -> Movement { use crate::shell::text::{Direction, Movement as SMovemement, VerticalMovement}; @@ -63,7 +64,8 @@ impl From for Movement { | SMovemement::Vertical(VerticalMovement::PageDown) => Movement::Down, SMovemement::Vertical(VerticalMovement::DocumentStart) => Movement::StartOfDocument, SMovemement::Vertical(VerticalMovement::DocumentEnd) => Movement::EndOfDocument, - _ => unreachable!(), + // the source enum is non_exhaustive + _ => panic!("unhandled movement {:?}", src), } } } diff --git a/druid/src/text/selection.rs b/druid/src/text/selection.rs index ffe227589b..5bb5cd3226 100644 --- a/druid/src/text/selection.rs +++ b/druid/src/text/selection.rs @@ -96,6 +96,7 @@ impl Selection { } } +//FIXME: delete this file, unify with druid-shell::text::Selection impl From for crate::shell::text::Selection { fn from(src: Selection) -> crate::shell::text::Selection { crate::shell::text::Selection { diff --git a/druid/src/widget/textbox.rs b/druid/src/widget/textbox.rs index 66f6cb4297..cbd8f8b2fe 100644 --- a/druid/src/widget/textbox.rs +++ b/druid/src/widget/textbox.rs @@ -49,7 +49,6 @@ pub struct TextBox { inner: Padding>>, scroll_to_selection_after_layout: bool, multiline: bool, - wrap_lines: bool, /// true if a click event caused us to gain focus. /// /// On macOS, if focus happens via click then we set the selection based @@ -78,7 +77,6 @@ impl TextBox { scroll_to_selection_after_layout: false, placeholder, multiline: false, - wrap_lines: false, was_focused_from_click: false, cursor_on: false, cursor_timer: TimerToken::INVALID, @@ -109,7 +107,6 @@ impl TextBox { /// /// [`multiline`]: TextBox::multiline pub fn with_line_wrapping(mut self, wrap_lines: bool) -> Self { - self.wrap_lines = wrap_lines; self.inner .wrapped_mut() .set_horizontal_scroll_enabled(!wrap_lines); @@ -278,26 +275,6 @@ impl TextBox { } impl TextBox { - ///// Set the textbox's selection. - //pub fn set_selection(&mut self, selection: Selection) { - //self.editor.set_selection(selection); - //} - - ///// Set the text and force the editor to update. - ///// - ///// This should be rarely needed; the main use-case would be if you need - ///// to manually set the text and then immediately do hit-testing or other - ///// tasks that rely on having an up-to-date text layout. - //pub fn force_rebuild(&mut self, text: T, factory: &mut PietText, env: &Env) { - //self.editor.set_text(text); - //self.editor.rebuild_if_needed(factory, env); - //} -} - -impl TextBox { - //FIXME: maybe a more restrictive API? some kind of `with_text` method that - //takes a closure and makes sure we're locked, and then also notifies the platform - //of changes afterwards? /// An immutable reference to the inner [`TextComponent`]. /// /// Using this correctly is difficult; please see the [`TextComponent`] @@ -330,22 +307,11 @@ impl TextBox { impl TextBox { fn rect_for_selection_end(&self) -> Rect { - let selection_end = self.text().borrow().selection().end; - let hit = self - .text() - .borrow() - .layout - .layout() - .unwrap() - .hit_test_text_position(selection_end); - let line = self - .text() - .borrow() - .layout - .layout() - .unwrap() - .line_metric(hit.line) - .unwrap(); + let text = self.text().borrow(); + let layout = text.layout.layout().unwrap(); + + let hit = layout.hit_test_text_position(text.selection().end); + let line = layout.line_metric(hit.line).unwrap(); let y0 = line.y_offset; let y1 = y0 + line.height; let x = hit.point.x; @@ -427,7 +393,7 @@ impl Widget for TextBox { { if self.text().borrow().set_clipboard() { let inval = self.text_mut().borrow_mut().insert_text(data, ""); - ctx.invalidate_text_input(Some(inval)); + ctx.invalidate_text_input(inval); } ctx.set_handled(); } @@ -440,7 +406,7 @@ impl Widget for TextBox { }; if !text.is_empty() { let inval = self.text_mut().borrow_mut().insert_text(data, text); - ctx.invalidate_text_input(Some(inval)); + ctx.invalidate_text_input(inval); } } } @@ -461,7 +427,7 @@ impl Widget for TextBox { if self.text().can_write() && !self.multiline && !self.was_focused_from_click { let selection = Selection::new(0, data.len()); let _ = self.text_mut().borrow_mut().set_selection(selection); - ctx.invalidate_text_input(Some(druid_shell::text::Event::SelectionChanged)); + ctx.invalidate_text_input(druid_shell::text::Event::SelectionChanged); } self.reset_cursor_blink(ctx.request_timer(CURSOR_BLINK_DURATION)); self.was_focused_from_click = false; @@ -472,7 +438,7 @@ impl Widget for TextBox { let selection = self.text().borrow().selection(); let selection = Selection::new(selection.end, selection.end); let _ = self.text_mut().borrow_mut().set_selection(selection); - ctx.invalidate_text_input(Some(druid_shell::text::Event::SelectionChanged)); + ctx.invalidate_text_input(druid_shell::text::Event::SelectionChanged); } self.cursor_timer = TimerToken::INVALID; self.was_focused_from_click = false; @@ -492,7 +458,7 @@ impl Widget for TextBox { if self.text().can_write() { if let Some(ime_invalidation) = self.text_mut().borrow_mut().pending_ime_invalidation() { - ctx.invalidate_text_input(Some(ime_invalidation)); + ctx.invalidate_text_input(ime_invalidation); } } } diff --git a/druid/src/widget/value_textbox.rs b/druid/src/widget/value_textbox.rs index d4f9b763d4..2079b3f1d0 100644 --- a/druid/src/widget/value_textbox.rs +++ b/druid/src/widget/value_textbox.rs @@ -1,4 +1,4 @@ -// Copyright 2018 The Druid Authors. +// Copyright 2021 The Druid Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -169,7 +169,7 @@ impl ValueTextBox { .borrow_mut() .set_selection(Selection::new(0, self.buffer.len())) { - ctx.invalidate_text_input(Some(inval)); + ctx.invalidate_text_input(inval); } } self.send_event(ctx, TextBoxEvent::Invalid(err)); @@ -304,7 +304,6 @@ impl Widget for ValueTextBox { self.force_selection = new_sel; if self.update_data_while_editing && !validation.is_err() { - //FIXME: notify platform of text change if let Ok(new_data) = self.formatter.value(&self.buffer) { *data = new_data; self.last_known_data = Some(data.clone()); @@ -360,7 +359,7 @@ impl Widget for ValueTextBox { if let Some(sel) = self.force_selection.take() { if self.inner.text().can_write() { if let Some(change) = self.inner.text_mut().borrow_mut().set_selection(sel) { - ctx.invalidate_text_input(Some(change)); + ctx.invalidate_text_input(change); } } } diff --git a/druid/src/window.rs b/druid/src/window.rs index 0bf0401829..01953156c9 100644 --- a/druid/src/window.rs +++ b/druid/src/window.rs @@ -285,7 +285,9 @@ impl Window { self.focus = new; // check if the newly focused widget has an IME session, and // notify the system if so. - //FIXME: this is quadratic. do we care? + // + // If you're here because a profiler sent you: I guess I should've + // used a hashmap? let old_was_ime = old .map(|old| { self.ime_handlers