diff --git a/CHANGELOG.md b/CHANGELOG.md index b6431659f..07087ce01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ Please only add new entries below the [Unreleased](#unreleased---releasedate) he - **core**: Refactor TextStyleWidget to support the setting of some font style fields. (#pr @wjian23) - **core**: Rename `can_focus` field of FocusScope to `skip_host`. (#pr @wjian23) - +- **widgets**: Refactor `Input` Widget. (#pr @wjian23) ## [0.4.0-alpha.20] - 2024-12-25 diff --git a/core/src/builtin_widgets/scrollable.rs b/core/src/builtin_widgets/scrollable.rs index 926e79d1c..34c7888e4 100644 --- a/core/src/builtin_widgets/scrollable.rs +++ b/core/src/builtin_widgets/scrollable.rs @@ -14,6 +14,22 @@ pub enum Scrollable { Both, } +pub struct ScrollViewInfo { + pub current: Point, + pub global_view: Rect, +} + +pub struct ScrollRequest(Box Point>); + +impl ScrollRequest { + pub fn new(f: impl Fn(ScrollViewInfo) -> Point + 'static) -> Self { Self(Box::new(f)) } +} + +/// A event request scroll to the given position. +/// The event will include a ScrollRequest which is a callback that receive the +/// current scroll position and return the new scroll position. +pub type ScrollRequestEvent = CustomEvent; + /// Helper struct for builtin scrollable field. #[derive(Default)] pub struct ScrollableWidget { @@ -62,6 +78,17 @@ impl<'c> ComposeChild<'c> for ScrollableWidget { .subscribe(move |v| $this.write().set_page(v)); @Clip { + on_custom_event: move |e: &mut ScrollRequestEvent| { + let mut view = $view.layout_rect(); + let mut write_ref = $this.write(); + view.origin = e.window().map_to_global(view.origin, e.current_target()); + let current = write_ref.get_scroll_pos(); + write_ref.jump_to((e.data().0)(ScrollViewInfo { + current, + global_view: view, + })); + e.stop_propagation(); + }, @ $view { on_wheel: move |e| $this.write().scroll(-e.delta_x, -e.delta_y), @ { child } diff --git a/core/src/builtin_widgets/text.rs b/core/src/builtin_widgets/text.rs index 4fdb3b3d9..1edde921c 100644 --- a/core/src/builtin_widgets/text.rs +++ b/core/src/builtin_widgets/text.rs @@ -16,6 +16,9 @@ pub struct Text { glyphs: RefCell>, } +impl ChildOfCompose for FatObj> {} +impl ChildOfCompose for Stateful {} + impl Render for Text { fn perform_layout(&self, clamp: BoxClamp, ctx: &mut LayoutCtx) -> Size { let style = ctx.text_style(); diff --git a/core/src/state.rs b/core/src/state.rs index e7e85079d..c23d67dfa 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -76,6 +76,15 @@ pub trait StateWatcher: StateReader { fn clone_watcher(&self) -> Watcher { Watcher::new(self.clone_reader(), self.raw_modifies()) } + + /// Return a new watcher by applying a function to the contained value. + fn map_watcher(&self, part_map: M) -> Watcher> + where + M: Fn(&Self::Value) -> PartData + Clone, + { + let reader = self.map_reader(part_map); + Watcher::new(reader, self.raw_modifies()) + } } pub trait StateWriter: StateWatcher { diff --git a/docs/en/practice_todos_app/develop_a_todos_app.md b/docs/en/practice_todos_app/develop_a_todos_app.md index db8d96bb8..da8b3ca09 100644 --- a/docs/en/practice_todos_app/develop_a_todos_app.md +++ b/docs/en/practice_todos_app/develop_a_todos_app.md @@ -589,7 +589,7 @@ impl Compose for Todos { $input.write().set_text(""); } }, - @{ Placeholder::new("What do you want to do ?") } + @Text { text:"What do you want to do ?" } } } @Tabs { diff --git a/docs/zh/practice_todos_app/develop_a_todos_app.md b/docs/zh/practice_todos_app/develop_a_todos_app.md index 79b3aaf35..fe2b9f390 100644 --- a/docs/zh/practice_todos_app/develop_a_todos_app.md +++ b/docs/zh/practice_todos_app/develop_a_todos_app.md @@ -592,7 +592,7 @@ impl Compose for Todos { $input.write().set_text(""); } }, - @{ Placeholder::new("What do you want to do ?") } + @Text { text: "What do you want to do ?" } } } @Tabs { diff --git a/examples/todos/src/ui.rs b/examples/todos/src/ui.rs index 02fc948aa..288b7e5c0 100644 --- a/examples/todos/src/ui.rs +++ b/examples/todos/src/ui.rs @@ -108,20 +108,26 @@ fn input( if let Some(text) = text { $input.write().set_text(&text); } - @ $input { - margin: EdgeInsets::horizontal(24.), - h_align: HAlign::Stretch, - border: { - let color = Palette::of(BuildCtx::get()).surface_variant().into(); - Border::only_bottom(BorderSide { width: 2., color }) - }, - on_key_down: move |e| { - if e.key_code() == &PhysicalKey::Code(KeyCode::Enter) { - on_submit($input.text().clone()); - $input.write().set_text(""); - } - }, - @{ Placeholder::new("What do you want to do ?") } + @ Stack { + padding: EdgeInsets::horizontal(24.), + @Text { + h_align: HAlign::Stretch, + visible: pipe!($input.text().is_empty()), + text: "What do you want to do ?" + } + @ $input { + h_align: HAlign::Stretch, + border: { + let color = Palette::of(BuildCtx::get()).surface_variant().into(); + Border::only_bottom(BorderSide { width: 2., color }) + }, + on_key_down: move |e| { + if e.key_code() == &PhysicalKey::Code(KeyCode::Enter) { + on_submit($input.text().clone()); + $input.write().set_text(""); + } + }, + } } } .into_widget() diff --git a/painter/src/text/typography_store.rs b/painter/src/text/typography_store.rs index 0bc9982e8..9ccd0f9f9 100644 --- a/painter/src/text/typography_store.rs +++ b/painter/src/text/typography_store.rs @@ -317,7 +317,7 @@ impl VisualGlyphs { ), }, |glyph| { - let orign = Point::new( + let origin = Point::new( self.to_pixel_value(glyph.x_offset + line.x), self.to_pixel_value(glyph.y_offset + line.y), ); @@ -327,7 +327,7 @@ impl VisualGlyphs { Size::new(self.to_pixel_value(glyph.x_advance), self.to_pixel_value(line.height)) } }; - Rect::new(orign, size) + Rect::new(origin, size) }, ); rc.origin += Point::new(self.to_pixel_value(self.x), self.to_pixel_value(self.y)).to_vector(); @@ -343,51 +343,53 @@ impl VisualGlyphs { } pub fn select_range(&self, rg: &Range) -> Vec { - struct TypoRectJointer { - acc: Vec, - cur: Option, - } - - impl TypoRectJointer { - fn new() -> Self { Self { acc: vec![], cur: None } } - fn join_x(&mut self, next: Rect) { - if let Some(rc) = &mut self.cur { - rc.size.width = next.max_x() - rc.min_x(); - } else { - self.cur = Some(next); - } - } - fn new_rect(&mut self) { - let cur = self.cur.take(); - if let Some(rc) = cur { - self.acc.push(rc); - } - } - fn rects(self) -> Vec { self.acc } - } - let mut jointer = TypoRectJointer::new(); + let mut rects = vec![]; for line in &self.visual_info.visual_lines { - let height = self.to_pixel_value(line.height); - let offset_x = self.x + line.x; - let offset_y = self.y + line.y; - for glyph in &line.glyphs { + let mut inline: Vec<(usize, usize)> = vec![]; + for (i, glyph) in line.glyphs.iter().enumerate() { if rg.contains(&(glyph.cluster as usize)) { - let glyph = glyph.clone().cast_to(self.font_size); - let rc = Rect::new( - Point::new( - self.to_pixel_value(glyph.x_offset + offset_x), - self.to_pixel_value(glyph.y_offset + offset_y), - ), - Size::new(self.to_pixel_value(glyph.x_advance), height), - ); - jointer.join_x(rc); - } else { - jointer.new_rect(); + if let Some((_, end)) = inline + .last_mut() + .filter(|(_, end)| (*end + 1) == i) + { + *end += 1; + } else { + inline.push((i, i)); + } } } - jointer.new_rect(); + + if !inline.is_empty() { + let is_horizontal = !self.visual_info.line_dir.is_horizontal(); + let offset_x = self.x + line.x; + let offset_y = self.y + line.y; + inline.into_iter().for_each(|(start, end)| { + let (x_min, x_max, y_min, y_max) = if is_horizontal { + ( + line.glyphs[start].x_offset, + line.glyphs[end].x_offset + line.glyphs[end].x_advance, + GlyphUnit::ZERO, + line.height, + ) + } else { + ( + GlyphUnit::ZERO, + line.width, + line.glyphs[start].y_offset, + line.glyphs[end].y_offset + line.glyphs[end].y_advance, + ) + }; + let rect = Rect::from_points([ + Point::new(self.to_pixel_value(x_min), self.to_pixel_value(y_min)), + Point::new(self.to_pixel_value(x_max), self.to_pixel_value(y_max)), + ]); + rects.push( + rect.translate((self.to_pixel_value(offset_x), self.to_pixel_value(offset_y)).into()), + ); + }); + } } - jointer.rects() + rects } fn to_pixel_value(&self, v: GlyphUnit) -> f32 { v.cast_to(self.font_size).into_pixel() } diff --git a/themes/material/src/classes.rs b/themes/material/src/classes.rs index 0423eb465..b5fc5f667 100644 --- a/themes/material/src/classes.rs +++ b/themes/material/src/classes.rs @@ -2,12 +2,12 @@ use ribir_core::prelude::Classes; mod buttons_cls; mod checkbox_cls; +mod input_cls; mod progress_cls; mod radio_cls; mod scrollbar_cls; mod slider_cls; mod tooltips_cls; - pub fn initd_classes() -> Classes { let mut classes = Classes::default(); @@ -18,6 +18,7 @@ pub fn initd_classes() -> Classes { checkbox_cls::init(&mut classes); tooltips_cls::init(&mut classes); slider_cls::init(&mut classes); + input_cls::init(&mut classes); classes } diff --git a/themes/material/src/classes/input_cls.rs b/themes/material/src/classes/input_cls.rs new file mode 100644 index 000000000..d3f956ff5 --- /dev/null +++ b/themes/material/src/classes/input_cls.rs @@ -0,0 +1,27 @@ +use ribir_core::prelude::*; +use ribir_widgets::{input::TEXT_CARET, prelude::*}; + +use crate::md; + +pub(super) fn init(classes: &mut Classes) { + classes.insert(TEXT_CARET, |_w| { + fn_widget! { + let mut w = @ FittedBox { + box_fit: BoxFit::CoverY, + @ { svgs::TEXT_CARET } + }; + let blink_interval = Duration::from_millis(500); + let unsub = interval(blink_interval, AppCtx::scheduler()) + .subscribe(move |idx| $w.write().opacity = (idx % 2) as f32); + @ $w { + on_disposed: move |_| unsub.unsubscribe() + } + } + .into_widget() + }); + + classes.insert(TEXT_HIGH_LIGHT, style_class! { + background: Color::from_rgb(181, 215, 254), + border_radius: md::RADIUS_2, + }); +} diff --git a/themes/material/src/lib.rs b/themes/material/src/lib.rs index 1bee32c11..62d84bb55 100644 --- a/themes/material/src/lib.rs +++ b/themes/material/src/lib.rs @@ -74,16 +74,6 @@ const AVATAR_RADIUS: f32 = 20.; const LIST_IMAGE_ITEM_SIZE: f32 = 56.; fn init_custom_style(theme: &mut Theme) { - theme - .custom_styles - .set_custom_style(InputStyle { size: Some(20.) }); - theme - .custom_styles - .set_custom_style(TextAreaStyle { rows: Some(2.), cols: Some(20.) }); - theme - .custom_styles - .set_custom_style(SelectedHighLightStyle { brush: Color::from_rgb(181, 215, 254).into() }); - theme.custom_styles.set_custom_style(TabsStyle { extent_with_both: 64., extent_only_label: 48., @@ -153,12 +143,6 @@ fn init_custom_style(theme: &mut Theme) { }, }, }); - theme - .custom_styles - .set_custom_style(PlaceholderStyle { - foreground: theme.palette.on_surface_variant().into(), - text_style: theme.typography_theme.body_medium.text.clone(), - }); } fn override_compose_decorator(theme: &mut Theme) { diff --git a/widgets/src/input.rs b/widgets/src/input.rs index 1d01547e2..9f6203208 100644 --- a/widgets/src/input.rs +++ b/widgets/src/input.rs @@ -1,108 +1,74 @@ -use ribir_core::{prelude::*, ticker::FrameMsg}; +use ribir_core::prelude::*; + +use crate::prelude::*; mod caret; mod caret_state; mod glyphs_helper; mod handle; -mod selected_text; + +mod text_editable; +mod text_high_light; mod text_selectable; -use std::ops::Range; +pub use caret::TEXT_CARET; pub use caret_state::{CaretPosition, CaretState}; -pub use selected_text::SelectedHighLightStyle; -pub use text_selectable::TextSelectable; - -use crate::{ - input::{ - caret::*, - handle::{TextCaretWriter, edit_handle, edit_key_handle}, - selected_text::*, - text_selectable::{SelectableText, bind_point_listener, select_key_handle}, - }, - layout::*, -}; - -#[derive(ChildOfCompose)] -pub struct Placeholder(DeclareInit>); - -impl Placeholder { - #[inline] - pub fn new(str: impl DeclareInto, M>) -> Self { - Self(str.declare_into()) - } -} - -#[derive(Clone)] -pub struct PlaceholderStyle { - pub text_style: TextStyle, - pub foreground: Brush, -} - -impl CustomStyle for PlaceholderStyle { - fn default_style(ctx: &impl ProviderCtx) -> Self { - Self { - foreground: Palette::of(ctx).on_surface_variant().into(), - text_style: TypographyTheme::of(ctx).body_medium.text.clone(), - } - } -} - -#[derive(Clone, PartialEq)] -pub struct InputStyle { - pub size: Option, -} - -impl CustomStyle for InputStyle { - fn default_style(_: &impl ProviderCtx) -> Self { InputStyle { size: Some(20.) } } -} - -#[derive(Clone, PartialEq)] -pub struct TextAreaStyle { - pub rows: Option, - pub cols: Option, -} - -impl CustomStyle for TextAreaStyle { - fn default_style(_: &impl ProviderCtx) -> Self { - TextAreaStyle { rows: Some(2.), cols: Some(20.) } - } -} - -pub trait EditableText: Sized { - fn text(&self) -> &CowArc; - - fn caret(&self) -> CaretState; - - fn set_text_with_caret(&mut self, text: &str, caret: CaretState); - - fn writer(&mut self) -> TextCaretWriter { TextCaretWriter::new(self) } -} +pub use handle::{EditableText, SelectableText}; +pub use text_editable::{TextChanged, TextChangedEvent, edit_text}; +pub use text_high_light::TEXT_HIGH_LIGHT; + +/// The `Input` struct is a widget that represents a text input field +/// that displays a single line of text. if you need multi line text, use +/// `[TextArea]` +/// +/// The Input will emit the [TextChangedEvent] event when the text is changed, +/// emit the [TextSelectChanged] event when the text selection is changed. +/// The Input also implement the [EditableText] trait, which you can set +/// the text and the caret selection. +/// +/// ## Example +/// +/// ```rust no_run +/// use ribir::prelude::*; +/// fn_widget! { +/// let input_val = @Text{ text: ""}; +/// @Column { +/// @Input { +/// on_custom_event: move |e: &mut TextChangedEvent| { +/// $input_val.write().text = e.data().text.clone(); +/// } +/// } +/// @Row { +/// @ Text { text: "the input value is:" } +/// @ { input_val } +/// } +/// } +/// } +/// ``` #[derive(Declare)] pub struct Input { - #[declare(default = TypographyTheme::of(BuildCtx::get()).body_large.text.clone())] - pub style: TextStyle, #[declare(skip)] text: CowArc, + #[declare(skip)] caret: CaretState, - #[declare(default = InputStyle::of(BuildCtx::get()).size)] - size: Option, } +/// The `TextArea` struct is a widget that represents a text input field +/// that displays multiple lines of text. for single line text, use `[Input]` +/// +/// The TextArea will emit the [TextChanged] event when the text is changed, +/// emit the [TextSelectChanged] event when the text selection is changed. +/// The TextArea also implement the [EditableText] trait, which you can set +/// the text and the caret selection. #[derive(Declare)] pub struct TextArea { - #[declare(default = TypographyTheme::of(BuildCtx::get()).body_large.text.clone())] - pub style: TextStyle, - #[declare(default = true)] - pub auto_wrap: bool, + /// if true, the text will be auto wrap when the text is too long + auto_wrap: bool, #[declare(skip)] text: CowArc, #[declare(skip)] caret: CaretState, - #[declare(default = TextAreaStyle::of(BuildCtx::get()).rows)] - rows: Option, - #[declare(default = TextAreaStyle::of(BuildCtx::get()).cols)] - cols: Option, } impl Input { @@ -116,18 +82,14 @@ impl TextArea { } impl SelectableText for Input { - fn select_range(&self) -> Range { self.caret.select_range() } - - fn text(&self) -> &CowArc { &self.text } - fn caret(&self) -> CaretState { self.caret } fn set_caret(&mut self, caret: CaretState) { self.caret = caret; } + + fn text(&self) -> CowArc { self.text.clone() } } impl EditableText for Input { - fn text(&self) -> &CowArc { &self.text } - fn caret(&self) -> CaretState { self.caret } fn set_text_with_caret(&mut self, text: &str, caret: CaretState) { let new_text = text.replace(['\r', '\n'], " "); self.text = new_text.into(); @@ -136,9 +98,7 @@ impl EditableText for Input { } impl SelectableText for TextArea { - fn select_range(&self) -> Range { self.caret.select_range() } - - fn text(&self) -> &CowArc { &self.text } + fn text(&self) -> CowArc { self.text.clone() } fn caret(&self) -> CaretState { self.caret } @@ -146,134 +106,21 @@ impl SelectableText for TextArea { } impl EditableText for TextArea { - fn text(&self) -> &CowArc { &self.text } - - fn caret(&self) -> CaretState { self.caret } - fn set_text_with_caret(&mut self, text: &str, caret: CaretState) { self.text = text.to_string().into(); self.caret = caret; } } -#[derive(Debug)] -struct PreEditState { - position: usize, - value: Option, -} - -struct ImeHandle { - host: H, - pre_edit: Option, - guard: Option>>, - window: Sc, - caret_id: TrackId, -} - -impl ImeHandle -where - E: EditableText + 'static, - H: StateWriter, -{ - fn new(window: Sc, host: H, caret_id: TrackId) -> Self { - Self { window, host, pre_edit: None, guard: None, caret_id } - } - fn ime_allowed(&mut self) { - self.window.set_ime_allowed(true); - self.track_cursor(); - } - - fn ime_disallowed(&mut self) { - self.window.set_ime_allowed(false); - self.guard = None; - } - - fn update_pre_edit(&mut self, e: &ImePreEditEvent) { - match &e.pre_edit { - ImePreEdit::Begin => { - let mut host = self.host.write(); - let rg = host.caret().select_range(); - host.writer().delete_byte_range(&rg); - self.pre_edit = Some(PreEditState { position: rg.start, value: None }); - } - ImePreEdit::PreEdit { value, cursor } => { - let Some(PreEditState { position, value: edit_value }) = self.pre_edit.as_mut() else { - return; - }; - let mut host = self.host.write(); - let mut writer = host.writer(); - if let Some(txt) = edit_value { - writer.delete_byte_range(&(*position..*position + txt.len())); - } - writer.insert_str(value); - writer.set_to(*position + cursor.map_or(0, |(start, _)| start)); - *edit_value = Some(value.clone()); - } - ImePreEdit::End => { - if let Some(PreEditState { value: Some(txt), position, .. }) = self.pre_edit.take() { - let mut host = self.host.write(); - let mut writer = host.writer(); - writer.delete_byte_range(&(position..position + txt.len())); - } - } - } - if self.pre_edit.is_none() { - self.track_cursor(); - } else { - self.guard = None; - } - } - - fn track_cursor(&mut self) { - if self.guard.is_some() { - return; - } - - let window = self.window.clone(); - let caret_id = self.caret_id.clone(); - let tick_of_layout_ready = window - .frame_tick_stream() - .filter(|msg| matches!(msg, FrameMsg::LayoutReady(_))); - self.guard = Some( - self - .host - .modifies() - .sample(tick_of_layout_ready) - .box_it() - .subscribe(move |_| { - if let Some(wid) = caret_id.get() { - let pos = window.map_to_global(Point::zero(), wid); - let size = window.widget_size(wid).unwrap_or_default(); - window.set_ime_cursor_area(&Rect::new(pos, size)); - } - }) - .unsubscribe_when_dropped(), - ); - } -} - -impl ComposeChild<'static> for Input { - type Child = Option; - fn compose_child( - this: impl StateWriter, placeholder: Self::Child, - ) -> Widget<'static> { +impl Compose for Input { + fn compose(this: impl StateWriter) -> Widget<'static> { fn_widget! { - let text = @Text { - text: pipe!($this.text.clone()), - text_style: pipe!($this.style.clone()), - }; @FocusScope { - can_focus: true, - @ConstrainedBox { - clamp: pipe!(size_clamp(&$this.style, Some(1.), $this.size)), - @ { - EditableTextExtraWidget::edit_area( - this.clone_writer(), - text, - BoxPipe::value(Scrollable::X).into_pipe(), - placeholder - ) - } + skip_host: true, + @TextClamp { + rows: Some(1.), + scrollable: Scrollable::X, + @ { edit_text(this.clone_writer()) } } } } @@ -281,201 +128,26 @@ impl ComposeChild<'static> for Input { } } -impl ComposeChild<'static> for TextArea { - type Child = Option; - fn compose_child( - this: impl StateWriter, placeholder: Self::Child, - ) -> Widget<'static> { +impl Compose for TextArea { + fn compose(this: impl StateWriter) -> Widget<'static> { fn_widget! { - let text = @Text { - text: pipe!($this.text.clone()), - text_style: pipe!{ - let this = $this; - let mut style = this.style.clone(); - let overflow = match this.auto_wrap { - true => TextOverflow::AutoWrap, - false => TextOverflow::Clip, - }; - if style.overflow != overflow { - style.overflow = overflow; - } - style - }, - }; - - let scroll_dir = pipe!($this.auto_wrap).map(|auto_wrap| match auto_wrap { - true => Scrollable::Y, - false => Scrollable::Both, - }); @FocusScope { - can_focus: true, - @ConstrainedBox { - clamp: pipe!(size_clamp(&$this.style, $this.rows, $this.cols)), - @EditableTextExtraWidget::edit_area( - this.clone_writer(), - text, - scroll_dir, - placeholder - ) - } - } - } - .into_widget() - } -} - -trait EditableTextExtraWidget: EditableText + SelectableText -where - Self: 'static, -{ - fn edit_area( - this: impl StateWriter, text: FatObj>, - scroll_dir: impl Pipe, placeholder: Option, - ) -> Widget<'static> { - fn_widget! { - let only_text = text.clone_reader(); - - let mut stack = @Stack { - fit: StackFit::Passthrough, - scrollable: scroll_dir, - }; - - let mut caret_box = @Caret { - focused: pipe!($stack.has_focus()), - clamp: pipe!( - $this.current_line_height(&$text).unwrap_or(0.) - ).map(BoxClamp::fixed_height), - }; - - let caret_box_id = $caret_box.track_id(); - let mut caret_box = @$caret_box { - anchor: pipe!( - let pos = $this.caret_position(&$text).unwrap_or_default(); - Anchor::left_top(pos.x, pos.y) - ), - }; - let scrollable = stack.get_scrollable_widget(); - let wnd = BuildCtx::get().window(); - let tick_of_layout_ready = wnd - .frame_tick_stream() - .filter(|msg| matches!(msg, FrameMsg::LayoutReady(_))); - watch!(SelectableText::caret(&*$this)) - .distinct_until_changed() - .sample(tick_of_layout_ready) - .map(move |_| $this.caret_position(&$text).unwrap_or_default()) - .scan_initial((Point::zero(), Point::zero()), |pair, v| (pair.1, v)) - .subscribe(move |(before, after)| { - let mut scrollable = $scrollable.silent(); - let pos = auto_scroll_pos(&scrollable, before, after, $caret_box.layout_size()); - scrollable.jump_to(pos); - }); - - let placeholder = @ { - placeholder.map(move |holder| @Text { - visible: pipe!(SelectableText::text(&*$this).is_empty()), - text: holder.0, - }) - }; - - let ime_handle = Stateful::new( - ImeHandle::new(wnd, this.clone_writer(), caret_box_id) - ); - let mut stack = @ $stack { - on_focus: move |_| $ime_handle.write().ime_allowed(), - on_blur: move |_| $ime_handle.write().ime_disallowed(), - on_chars: move |c| { - let _hint_capture_writer = || $this.write(); - edit_handle(&this, c); - }, - on_key_down: move |k| { - let _hint_capture_writer = || $this.write(); - select_key_handle(&this, &$only_text, k); - edit_key_handle(&this, k); - }, - on_ime_pre_edit: move |e| { - $ime_handle.write().update_pre_edit(e); - }, - }; - - let high_light_rect = @UnconstrainedBox { - clamp_dim: ClampDim::MIN_SIZE, - @OnlySizedByParent { - @SelectedHighLight { - visible: pipe!($stack.has_focus()), - rects: pipe! { $this.select_text_rect(&$text) } - } + skip_host: true, + @Scrollbar { + scrollable: pipe!($this.auto_wrap).map(|v| if v { + Scrollable::Y + } else { + Scrollable::Both + }), + text_overflow: TextOverflow::AutoWrap, + @ { edit_text(this.clone_writer()) } } - }; - - let caret = @UnconstrainedBox { - clamp_dim: ClampDim::MIN_SIZE, - @OnlySizedByParent { @ {caret_box } } - }; - - let text_widget = text.into_widget(); - let text_widget = bind_point_listener( - this.clone_writer(), - text_widget, - only_text - ); - - @ $stack { - padding: EdgeInsets::horizontal(2.), - @ { placeholder } - @ { high_light_rect } - @ { caret } - @ { text_widget } } } .into_widget() } } -impl EditableTextExtraWidget for TextArea {} - -impl EditableTextExtraWidget for Input {} - -fn size_clamp(style: &TextStyle, rows: Option, cols: Option) -> BoxClamp { - let mut clamp: BoxClamp = - BoxClamp { min: Size::new(0., 0.), max: Size::new(f32::INFINITY, f32::INFINITY) }; - if let Some(cols) = cols { - let width = cols * style.font_size; - clamp = clamp.with_fixed_width(width); - } - if let Some(rows) = rows { - clamp = clamp.with_fixed_height(rows * style.line_height); - } - clamp -} - -fn auto_scroll_pos(container: &ScrollableWidget, before: Point, after: Point, size: Size) -> Point { - let view_size = container.scroll_view_size(); - let content_size = container.scroll_content_size(); - let current = container.get_scroll_pos(); - if view_size.contains(content_size) { - return current; - } - - let calc_offset = |current, before, after, max_size, size| { - let view_after = current + after; - let view_before = current + before; - let best_position = if !(0. <= view_before + size && view_before < max_size) { - (max_size - size) / 2. - } else if view_after < 0. { - 0. - } else if view_after > max_size - size { - max_size - size - } else { - view_after - }; - current + best_position - view_after - }; - Point::new( - calc_offset(current.x, before.x, after.x, view_size.width, size.width), - calc_offset(current.y, before.y, after.y, view_size.height, size.height), - ) -} - #[cfg(test)] mod tests { use ribir_core::{prelude::*, reset_test_env, test_helper::*}; @@ -508,7 +180,7 @@ mod tests { reset_test_env!(); let (value, w_value) = split_value(String::default()); let w = fn_widget! { - let input = @Input { size: None }; + let input = @Input { }; watch!($input.text.clone()) .subscribe(move |text| *$w_value.write() = text.to_string()); diff --git a/widgets/src/input/caret.rs b/widgets/src/input/caret.rs index 480f4d957..b02b00373 100644 --- a/widgets/src/input/caret.rs +++ b/widgets/src/input/caret.rs @@ -1,36 +1,52 @@ use ribir_core::prelude::*; -#[derive(Declare)] -pub struct Caret { - pub focused: bool, - #[declare(default = svgs::TEXT_CARET)] - pub icon: NamedSvg, +use ticker::FrameMsg; + +use crate::{input::glyphs_helper::GlyphsHelper, prelude::*}; + +class_names! { + #[doc = "Class name for the text caret"] + TEXT_CARET, } +#[derive(Declare)] +pub struct Caret {} + impl Compose for Caret { - fn compose(this: impl StateWriter) -> Widget<'static> { - let blink_interval = Duration::from_millis(500); + fn compose(_this: impl StateWriter) -> Widget<'static> { fn_widget! { - let icon = FatObj::new($this.icon); - let mut caret = @ $icon { - opacity: 0., - box_fit: BoxFit::CoverY, - }; - let mut _guard = None; - let u = watch!($this.focused) - .subscribe(move |focused| { - if focused { - $caret.write().opacity = 1.; - let unsub = interval(blink_interval, AppCtx::scheduler()) - .subscribe(move |idx| $caret.write().opacity = (idx % 2) as f32) - .unsubscribe_when_dropped(); - _guard = Some(unsub); - } else { - $caret.write().opacity = 0.; - _guard = None; + @IgnorePointer { + @TextClamp { + rows: Some(1.), + @ Void { + class: TEXT_CARET } - }); - @ $caret { on_disposed: move |_| u.unsubscribe() } + } + } } .into_widget() } } + +pub fn caret_widget( + caret: impl StateWatcher, text: impl StateWatcher, +) -> Widget<'static> { + fn_widget! { + let tick_of_layout_ready = BuildCtx::get().window() + .frame_tick_stream() + .filter(|msg| matches!(msg, FrameMsg::LayoutReady(_))); + + @Caret { + anchor: pipe!(($text.text.clone(), *$caret)).value_chain(|v|{ + v.sample(tick_of_layout_ready).box_it() + }).map(move |_| { + if let Some(glyphs) = $text.glyphs() { + let pos = glyphs.cursor($caret.caret_position()); + Anchor::from_point(pos) + } else { + Anchor::default() + } + }), + } + } + .into_widget() +} diff --git a/widgets/src/input/caret_state.rs b/widgets/src/input/caret_state.rs index 1435fe4a4..86fc76ee1 100644 --- a/widgets/src/input/caret_state.rs +++ b/widgets/src/input/caret_state.rs @@ -4,7 +4,6 @@ use std::ops::Range; pub enum CaretState { Caret(CaretPosition), Select(CaretPosition, CaretPosition), - Selecting(CaretPosition, CaretPosition), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -28,48 +27,18 @@ impl CaretState { CaretState::Select(begin, end) => { Range { start: begin.cluster.min(end.cluster), end: begin.cluster.max(end.cluster) } } - CaretState::Selecting(begin, end) => { - Range { start: begin.cluster.min(end.cluster), end: begin.cluster.max(end.cluster) } - } } } pub fn cluster(&self) -> usize { match *self { - CaretState::Caret(cursor) - | CaretState::Select(_, cursor) - | CaretState::Selecting(_, cursor) => cursor.cluster, + CaretState::Caret(cursor) | CaretState::Select(_, cursor) => cursor.cluster, } } pub fn caret_position(&self) -> CaretPosition { match *self { - CaretState::Caret(cursor) - | CaretState::Select(_, cursor) - | CaretState::Selecting(_, cursor) => cursor, - } - } - - pub fn valid(self, len: usize) -> Self { - match self { - CaretState::Caret(caret) => { - CaretPosition { cluster: caret.cluster.min(len), position: None }.into() - } - CaretState::Select(begin, end) => { - let begin = CaretState::from(begin).valid(len); - let end = CaretState::from(end).valid(len); - if begin == end { - begin - } else { - CaretState::Select(begin.caret_position(), end.caret_position()) - } - } - CaretState::Selecting(begin, end) => CaretState::Selecting( - CaretState::from(begin) - .valid(len) - .caret_position(), - CaretState::from(end).valid(len).caret_position(), - ), + CaretState::Caret(cursor) | CaretState::Select(_, cursor) => cursor, } } } diff --git a/widgets/src/input/glyphs_helper.rs b/widgets/src/input/glyphs_helper.rs index 9c87923ef..dcf302dd3 100644 --- a/widgets/src/input/glyphs_helper.rs +++ b/widgets/src/input/glyphs_helper.rs @@ -4,78 +4,8 @@ use ribir_core::prelude::*; use super::caret_state::CaretPosition; -impl SingleKeyMap -where - K: Eq, -{ - fn get(&self, key: &K) -> Option<&V> { - self - .0 - .as_ref() - .filter(|(k, _)| k == key) - .map(|(_, v)| v) - } -} - -struct SingleKeyMap(Option<(K, V)>); - -impl Default for SingleKeyMap { - fn default() -> Self { Self(None) } -} - -#[derive(Default)] -pub(crate) struct TextGlyphsHelper { - helper: SingleKeyMap, VisualGlyphs>, -} - -impl TextGlyphsHelper { - pub(crate) fn new(text: CowArc, glyphs: VisualGlyphs) -> Self { - Self { helper: SingleKeyMap(Some((text, glyphs))) } - } - - pub(crate) fn line_end(&self, text: &CowArc, caret: CaretPosition) -> Option { - self.helper.get(text)?.line_end(caret).into() - } - - pub(crate) fn line_begin( - &self, text: &CowArc, caret: CaretPosition, - ) -> Option { - self.helper.get(text)?.line_begin(caret).into() - } - - pub(crate) fn prev(&self, text: &CowArc, caret: CaretPosition) -> Option { - self.helper.get(text)?.prev(caret).into() - } - - pub(crate) fn next(&self, text: &CowArc, caret: CaretPosition) -> Option { - self.helper.get(text)?.next(caret).into() - } - - pub(crate) fn up(&self, text: &CowArc, caret: CaretPosition) -> Option { - self.helper.get(text)?.up(caret).into() - } - - pub(crate) fn down(&self, text: &CowArc, caret: CaretPosition) -> Option { - self.helper.get(text)?.down(caret).into() - } - - pub(crate) fn cursor(&self, text: &CowArc, caret: CaretPosition) -> Option { - let this = self.helper.get(text)?; - this.cursor(caret).into() - } - - pub(crate) fn line_height(&self, text: &CowArc, caret: CaretPosition) -> Option { - let this = self.helper.get(text)?; - this.line_height_by_caret(caret).into() - } - - pub(crate) fn selection(&self, text: &CowArc, rg: &Range) -> Option> { - self.helper.get(text)?.selection(rg).into() - } -} - pub(crate) trait GlyphsHelper { - fn caret_position_from_pos(&self, x: f32, y: f32) -> CaretPosition; + fn caret_position_from_pos(&self, pos: Point) -> CaretPosition; fn line_end(&self, caret: CaretPosition) -> CaretPosition; @@ -93,18 +23,16 @@ pub(crate) trait GlyphsHelper { fn cursor(&self, caret: CaretPosition) -> Point; - fn line_height_by_caret(&self, caret: CaretPosition) -> f32; - fn selection(&self, rg: &Range) -> Vec; fn caret_position(&self, caret: CaretPosition) -> (usize, usize); } impl GlyphsHelper for VisualGlyphs { - fn caret_position_from_pos(&self, x: f32, y: f32) -> CaretPosition { - let (para, mut offset) = self.nearest_glyph(x, y); + fn caret_position_from_pos(&self, pos: Point) -> CaretPosition { + let (para, mut offset) = self.nearest_glyph(pos.x, pos.y); let rc = self.glyph_rect(para, offset); - if (rc.min_x() - x).abs() > (rc.max_x() - x).abs() { + if (rc.min_x() - pos.x).abs() > (rc.max_x() - pos.x).abs() { offset += 1; } let cluster = self.position_to_cluster(para, offset); @@ -177,19 +105,14 @@ impl GlyphsHelper for VisualGlyphs { fn cursor(&self, caret: CaretPosition) -> Point { let (row, col) = self.caret_position(caret); if col == 0 { - let glphy = self.glyph_rect(row, col); - Point::new(glphy.min_x(), glphy.min_y()) + let glyph = self.glyph_rect(row, col); + Point::new(glyph.min_x(), glyph.min_y()) } else { - let glphy = self.glyph_rect(row, col - 1); - Point::new(glphy.max_x(), glphy.min_y()) + let glyph = self.glyph_rect(row, col - 1); + Point::new(glyph.max_x(), glyph.min_y()) } } - fn line_height_by_caret(&self, caret: CaretPosition) -> f32 { - let (row, _col) = self.caret_position(caret); - self.line_height(row) - } - fn selection(&self, rg: &Range) -> Vec { if rg.is_empty() { return vec![]; diff --git a/widgets/src/input/handle.rs b/widgets/src/input/handle.rs index 29e787675..eb522a5d1 100644 --- a/widgets/src/input/handle.rs +++ b/widgets/src/input/handle.rs @@ -1,12 +1,113 @@ #![allow(clippy::needless_lifetimes)] use std::ops::{Deref, DerefMut}; -use ribir_core::prelude::{ - AppCtx, CharsEvent, GraphemeCursor, KeyCode, KeyboardEvent, NamedKey, PhysicalKey, StateWriter, - TextWriter, VirtualKey, +use ribir_core::{ + events::{ImePreEdit, ImePreEditEvent}, + prelude::{ + AppCtx, CharsEvent, CowArc, GraphemeCursor, KeyCode, KeyboardEvent, NamedKey, PhysicalKey, + StateWriter, Text, TextWriter, VirtualKey, select_next_word, select_prev_word, select_word, + }, }; -use super::EditableText; +use super::{ + CaretPosition, CaretState, SelectRegionData, SelectRegionEvent, glyphs_helper::GlyphsHelper, +}; + +pub trait EditableText: SelectableText { + fn set_text_with_caret(&mut self, text: &str, caret: CaretState); + + fn chars_handle(&mut self, event: &CharsEvent) -> bool { + if event.common.with_command_key() { + return false; + } + + let chars = event + .chars + .chars() + .filter(|c| !c.is_control() || c.is_ascii_whitespace()) + .collect::(); + if !chars.is_empty() { + let rg = self.caret().select_range(); + let mut writer = TextCaretWriter::new(self); + writer.delete_byte_range(&rg); + writer.insert_str(&chars); + return true; + } + false + } + + fn keys_handle(&mut self, text: &Text, event: &KeyboardEvent) -> bool { + if self.keys_select_handle(text, event) { + return true; + } + let mut deal = false; + if event.with_command_key() { + deal = edit_with_command(self, event); + } + if !deal { + deal = edit_with_key(self, event); + } + deal + } +} + +pub trait SelectableText: Sized { + fn text(&self) -> CowArc; + + fn caret(&self) -> CaretState; + + fn set_caret(&mut self, caret: CaretState); + + fn keys_select_handle(&mut self, text: &Text, event: &KeyboardEvent) -> bool { + if self.text() != text.text { + return false; + } + + let mut deal = false; + if event.with_command_key() { + deal = select_with_command(self, event); + } + + if !deal { + deal = select_with_key(self, text, event); + } + deal + } + + fn select_region_handle(&mut self, text: &Text, e: &SelectRegionEvent) -> bool { + let glyphs = text.glyphs().unwrap(); + let e = e.data(); + let caret = match e { + SelectRegionData::SelectRect { from, to } => { + let begin = glyphs.caret_position_from_pos(*from); + let end = glyphs.caret_position_from_pos(*to); + CaretState::Select(begin, end) + } + SelectRegionData::SetTo(pos) => { + let caret = glyphs.caret_position_from_pos(*pos); + CaretState::Caret(caret) + } + SelectRegionData::ShiftTo(pos) => { + let caret = glyphs.caret_position_from_pos(*pos); + match self.caret() { + CaretState::Select(begin, _) | CaretState::Caret(begin) => { + CaretState::Select(begin, caret) + } + } + } + SelectRegionData::DoubleSelect(pos) => { + let caret = glyphs.caret_position_from_pos(*pos); + let rg = select_word(&text.text, caret.cluster); + CaretState::Select(CaretPosition { cluster: rg.start, position: None }, CaretPosition { + cluster: rg.end, + position: None, + }) + } + }; + self.set_caret(caret); + true + } +} pub struct TextCaretWriter<'a, H> where @@ -56,39 +157,60 @@ where fn deref_mut(&mut self) -> &mut Self::Target { &mut self.writer } } -pub(crate) fn edit_handle(this: &impl StateWriter, event: &CharsEvent) { - if event.common.with_command_key() { - return; - } - let chars = event - .chars - .chars() - .filter(|c| !c.is_control() || c.is_ascii_whitespace()) - .collect::(); - if !chars.is_empty() { - let mut this = this.write(); - let rg = this.caret().select_range(); - let mut writer = TextCaretWriter::new(&mut *this); - writer.delete_byte_range(&rg); - writer.insert_str(&chars); - } +#[derive(Debug)] +struct PreEditState { + position: usize, + value: Option, } -pub(crate) fn edit_key_handle( - this: &impl StateWriter, event: &KeyboardEvent, -) { - let mut deal = false; - if event.with_command_key() { - deal = key_with_command(this, event) - } - if !deal { - single_key(this, event); +pub struct ImeHandle { + host: H, + pre_edit: Option, +} + +impl ImeHandle +where + E: EditableText, + H: StateWriter, +{ + pub fn new(host: H) -> Self { Self { host, pre_edit: None } } + + pub fn is_in_pre_edit(&self) -> bool { self.pre_edit.is_some() } + + pub fn process_pre_edit(&mut self, e: &ImePreEditEvent) { + match &e.pre_edit { + ImePreEdit::Begin => { + let mut host = self.host.write(); + let rg = host.caret().select_range(); + let mut writer = TextCaretWriter::new(&mut *host); + writer.delete_byte_range(&rg); + self.pre_edit = Some(PreEditState { position: rg.start, value: None }); + } + ImePreEdit::PreEdit { value, cursor } => { + let Some(PreEditState { position, value: edit_value }) = self.pre_edit.as_mut() else { + return; + }; + let mut host = self.host.write(); + let mut writer = TextCaretWriter::new(&mut *host); + if let Some(txt) = edit_value { + writer.delete_byte_range(&(*position..*position + txt.len())); + } + writer.insert_str(value); + writer.set_to(*position + cursor.map_or(0, |(start, _)| start)); + *edit_value = Some(value.clone()); + } + ImePreEdit::End => { + if let Some(PreEditState { value: Some(txt), position, .. }) = self.pre_edit.take() { + let mut host = self.host.write(); + let mut writer = TextCaretWriter::new(&mut *host); + writer.delete_byte_range(&(position..position + txt.len())); + } + } + } } } -fn key_with_command( - this: &impl StateWriter, event: &KeyboardEvent, -) -> bool { +fn edit_with_command(this: &mut F, event: &KeyboardEvent) -> bool { if !event.with_command_key() { return false; } @@ -100,9 +222,8 @@ fn key_with_command( let clipboard = AppCtx::clipboard(); let txt = clipboard.borrow_mut().read_text(); if let Ok(txt) = txt { - let mut this = this.write(); let rg = this.caret().select_range(); - let mut writer = TextCaretWriter::new(&mut *this); + let mut writer = TextCaretWriter::new(this); if !rg.is_empty() { writer.delete_byte_range(&rg); } @@ -111,9 +232,8 @@ fn key_with_command( true } PhysicalKey::Code(KeyCode::KeyX) => { - let rg = this.read().caret().select_range(); + let rg = this.caret().select_range(); if !rg.is_empty() { - let mut this = this.write(); let txt = this.text().substr(rg.clone()).to_string(); TextCaretWriter::new(&mut *this).delete_byte_range(&rg); let clipboard = AppCtx::clipboard(); @@ -126,10 +246,9 @@ fn key_with_command( } } -fn single_key(this: &impl StateWriter, key: &KeyboardEvent) -> bool { +fn edit_with_key(this: &mut F, key: &KeyboardEvent) -> bool { match key.key() { VirtualKey::Named(NamedKey::Backspace) => { - let mut this = this.write(); let rg = this.caret().select_range(); if rg.is_empty() { TextCaretWriter::new(&mut *this).back_space(); @@ -138,7 +257,6 @@ fn single_key(this: &impl StateWriter, key: &Keyboar } } VirtualKey::Named(NamedKey::Delete) => { - let mut this = this.write(); let rg = this.caret().select_range(); if rg.is_empty() { TextCaretWriter::new(&mut *this).del_char(); @@ -150,3 +268,86 @@ fn single_key(this: &impl StateWriter, key: &Keyboar }; true } +fn select_with_command(this: &mut impl SelectableText, event: &KeyboardEvent) -> bool { + // use the physical key to make sure the keyboard with different + // layout use the same key as shortcut. + match event.key_code() { + PhysicalKey::Code(KeyCode::KeyC) => { + let rg = this.caret().select_range(); + let text = this.text(); + let selected_text = &text[rg]; + if !text.is_empty() { + let clipboard = AppCtx::clipboard(); + let _ = clipboard.borrow_mut().clear(); + let _ = clipboard.borrow_mut().write_text(selected_text); + } + true + } + PhysicalKey::Code(KeyCode::KeyA) => { + let len = this.text().len(); + if len > 0 { + this.set_caret(CaretState::Select( + CaretPosition { cluster: 0, position: None }, + CaretPosition { cluster: len, position: None }, + )); + } + true + } + _ => false, + } +} + +fn is_move_by_word(event: &KeyboardEvent) -> bool { + #[cfg(target_os = "macos")] + return event.with_alt_key(); + #[cfg(not(target_os = "macos"))] + return event.with_ctrl_key(); +} + +fn select_with_key(this: &mut impl SelectableText, text: &Text, event: &KeyboardEvent) -> bool { + let Some(glyphs) = text.glyphs() else { return false }; + + let old_caret = this.caret(); + let text = text.text.clone(); + let new_caret_position = match event.key() { + VirtualKey::Named(NamedKey::ArrowLeft) => { + if is_move_by_word(event) { + let cluster = select_prev_word(&text, old_caret.cluster(), false).start; + Some(CaretPosition { cluster, position: None }) + } else if event.with_command_key() { + Some(glyphs.line_begin(old_caret.caret_position())) + } else { + Some(glyphs.prev(old_caret.caret_position())) + } + } + VirtualKey::Named(NamedKey::ArrowRight) => { + if is_move_by_word(event) { + let cluster = select_next_word(&text, old_caret.cluster(), true).end; + Some(CaretPosition { cluster, position: None }) + } else if event.with_command_key() { + Some(glyphs.line_end(old_caret.caret_position())) + } else { + Some(glyphs.next(old_caret.caret_position())) + } + } + VirtualKey::Named(NamedKey::ArrowUp) => Some(glyphs.up(old_caret.caret_position())), + VirtualKey::Named(NamedKey::ArrowDown) => Some(glyphs.down(old_caret.caret_position())), + VirtualKey::Named(NamedKey::Home) => Some(glyphs.line_begin(old_caret.caret_position())), + VirtualKey::Named(NamedKey::End) => Some(glyphs.line_end(old_caret.caret_position())), + _ => None, + }; + + if new_caret_position.is_some() { + let caret = if event.with_shift_key() { + match old_caret { + CaretState::Caret(begin) | CaretState::Select(begin, _) => { + CaretState::Select(begin, new_caret_position.unwrap()) + } + } + } else { + new_caret_position.unwrap().into() + }; + this.set_caret(caret); + } + new_caret_position.is_some() +} diff --git a/widgets/src/input/selected_text.rs b/widgets/src/input/selected_text.rs deleted file mode 100644 index 5a814b802..000000000 --- a/widgets/src/input/selected_text.rs +++ /dev/null @@ -1,39 +0,0 @@ -use ribir_core::prelude::*; - -use crate::layout::Stack; - -#[derive(Declare)] -pub(crate) struct SelectedHighLight { - pub(crate) rects: Vec, -} - -#[derive(Clone, PartialEq)] -pub struct SelectedHighLightStyle { - pub brush: Brush, -} -impl CustomStyle for SelectedHighLightStyle { - fn default_style(_: &impl ProviderCtx) -> Self { - SelectedHighLightStyle { brush: Color::from_rgb(181, 215, 254).into() } - } -} - -impl Compose for SelectedHighLight { - fn compose(this: impl StateWriter) -> Widget<'static> { - fn_widget! { - let color = SelectedHighLightStyle::of(BuildCtx::get()).brush; - @Stack { - @ { pipe!{ - let color = color.clone(); - $this.rects.clone().into_iter().map(move |rc| { - @Container { - background: color.clone(), - anchor: Anchor::from_point(rc.origin), - size: rc.size, - } - }) - }} - } - } - .into_widget() - } -} diff --git a/widgets/src/input/text_editable.rs b/widgets/src/input/text_editable.rs new file mode 100644 index 000000000..b194af605 --- /dev/null +++ b/widgets/src/input/text_editable.rs @@ -0,0 +1,122 @@ +use ribir_core::prelude::*; + +use super::{ + caret::caret_widget, + handle::{EditableText, ImeHandle}, + text_selectable::{TextSelectChanged, TextSelectChangedEvent, TextSelectable}, +}; +use crate::{input::text_selectable::TextSelectableDeclareExtend, prelude::*}; + +pub struct TextChanged { + pub text: CowArc, + pub caret: CaretState, +} + +pub type TextChangedEvent = CustomEvent; + +fn notify_changed(track_id: TrackId, text: CowArc, caret: CaretState, wnd: &Window) { + wnd.bubble_custom_event(track_id.get().unwrap(), TextChanged { text, caret }); +} + +pub fn edit_text(this: impl StateWriter) -> Widget<'static> { + fn_widget! { + let text = @Text { text: pipe!($this.text().clone()) }; + let mut stack = @Stack {}; + let mut caret = FatObj::new(@ { + let caret_writer = this.map_writer(|v| PartData::from_data(v.caret())); + let text_writer = text.clone_writer(); + pipe!($stack.has_focus()).map(move |v| + if v { + caret_widget(caret_writer.clone_watcher(), text_writer.clone_watcher()) + } else { + @Void{}.into_widget() + } + ) + }); + + let wnd = BuildCtx::get().window(); + let ime = Stateful::new(ImeHandle::new(this.clone_writer())); + watch!($caret.layout_rect()) + .scan_initial((Rect::zero(), Rect::zero()), |pair, v| (pair.1, v)) + .subscribe(move |(mut before, mut after)| { + if let Some(wid) = $stack.track_id().get() { + let offset = wnd.map_to_global(Point::zero(), wid).to_vector(); + after.origin += offset; + before.origin += offset; + wnd.bubble_custom_event(wid, ScrollRequest::new(move |view_info: ScrollViewInfo| { + auto_scroll_pos(view_info.current, view_info.global_view, before, after) + })); + if $stack.has_focus() && !$ime.is_in_pre_edit(){ + wnd.set_ime_cursor_area(&after); + } + } + }); + + @ $stack { + on_focus: move |e| { e.window().set_ime_allowed(true); }, + on_blur: move |e| { e.window().set_ime_allowed(false); }, + on_chars: move |c| { + let mut this = $this.write(); + if this.chars_handle(c) { + notify_changed($stack.track_id(), this.text().clone(), this.caret(), &c.window()); + } else { + this.forget_modifies(); + } + }, + on_key_down: move |k| { + let mut this = $this.write(); + if this.keys_handle(&$text, k) { + notify_changed($stack.track_id(), this.text().clone(), this.caret(), &k.window()); + } else { + this.forget_modifies(); + } + }, + on_ime_pre_edit: move|e| { + $ime.write().process_pre_edit(e); + notify_changed($stack.track_id(), $this.text().clone(), $this.caret(), &e.window()); + }, + @ TextSelectable { + caret: pipe!($this.caret()), + margin: pipe!($caret.layout_size()).map(|v|EdgeInsets::only_right(v.width)), + on_custom_event: move |e: &mut TextSelectChangedEvent| { + let TextSelectChanged { text, caret } = e.data(); + if text == &$this.text() { + $this.write().set_caret(*caret); + } + }, + @ { text } + } + @ { caret } + } + } + .into_widget() +} + +fn auto_scroll_pos(scroll_pos: Point, view_rect: Rect, mut before: Rect, mut after: Rect) -> Point { + if view_rect.contains_rect(&after) { + return scroll_pos; + } + before = before.translate(-view_rect.origin.to_vector()); + after = after.translate(-view_rect.origin.to_vector()); + + let calc_offset = |before_min, before_max, after_min, after_max, view_size| { + let size = after_max - after_min; + let best_position = if before_min < 0. || view_size < before_max { + (view_size - size) / 2. + } else if after_min < 0. { + 0. + } else if view_size < after_max { + view_size - size + } else { + after_min + }; + + after_min - best_position + }; + + let offset = Point::new( + calc_offset(before.min_x(), before.max_x(), after.min_x(), after.max_x(), view_rect.width()), + calc_offset(before.min_y(), before.max_y(), after.min_y(), after.max_y(), view_rect.height()), + ); + scroll_pos + offset.to_vector() +} diff --git a/widgets/src/input/text_high_light.rs b/widgets/src/input/text_high_light.rs new file mode 100644 index 000000000..784c11ac7 --- /dev/null +++ b/widgets/src/input/text_high_light.rs @@ -0,0 +1,56 @@ +use ribir_core::prelude::*; +use ticker::FrameMsg; + +use super::{CaretState, OnlySizedByParent}; +use crate::{input::glyphs_helper::GlyphsHelper, layout::Stack}; + +class_names! { + #[doc = "Class name for the text high light rect"] + TEXT_HIGH_LIGHT, +} + +#[derive(Declare)] +pub struct TextHighLight { + pub rects: Vec, +} + +impl Compose for TextHighLight { + fn compose(this: impl StateWriter) -> Widget<'static> { + fn_widget! { + @Stack { + @ { pipe!{ + $this.rects.clone().into_iter().map(move |rc| { + @Container { + class: TEXT_HIGH_LIGHT, + anchor: Anchor::from_point(rc.origin), + size: rc.size, + } + }) + }} + } + } + .into_widget() + } +} + +pub fn high_light_widget( + caret: impl StateWatcher, text: impl StateWatcher, +) -> Widget<'static> { + fn_widget! { + let tick_of_layout_ready = BuildCtx::get().window() + .frame_tick_stream() + .filter(|msg| matches!(msg, FrameMsg::LayoutReady(_))); + @OnlySizedByParent { + @TextHighLight { + rects: pipe!((*$caret, $text.text.clone())) + .value_chain(move |v| v.sample(tick_of_layout_ready).box_it()) + .map(move|_| $text + .glyphs() + .map(|glyphs| glyphs.selection(&$caret.select_range())) + .unwrap_or_default() + ), + } + } + } + .into_widget() +} diff --git a/widgets/src/input/text_selectable.rs b/widgets/src/input/text_selectable.rs index 5a2d0fa79..ee3db53a1 100644 --- a/widgets/src/input/text_selectable.rs +++ b/widgets/src/input/text_selectable.rs @@ -1,279 +1,113 @@ -use std::ops::Range; - use ribir_core::prelude::*; -use super::glyphs_helper::TextGlyphsHelper; -use crate::{ - input::{glyphs_helper::GlyphsHelper, selected_text::*}, - prelude::*, -}; +use super::text_high_light::high_light_widget; +use crate::prelude::*; -#[derive(Declare, Default)] -pub struct TextSelectable { - #[declare(default)] +pub struct TextSelectChanged { + pub text: CowArc, pub caret: CaretState, - - #[declare(skip)] - text: CowArc, } -pub trait SelectableText { - fn selected_text(&self) -> Substr { - let rg = self.select_range(); - self.text().substr(rg) - } - - fn select_range(&self) -> Range; - - fn text(&self) -> &CowArc; - - fn caret(&self) -> CaretState; - - fn set_caret(&mut self, caret: CaretState); - - fn select_text_rect(&self, text: &Text) -> Vec { - text - .glyphs() - .and_then(|glyphs| { - let helper = TextGlyphsHelper::new(text.text.clone(), glyphs.clone()); - helper.selection(self.text(), &self.select_range()) - }) - .unwrap_or_default() - } - - fn caret_position(&self, text: &Text) -> Option { - text.glyphs().and_then(|glyphs| { - let helper = TextGlyphsHelper::new(text.text.clone(), glyphs.clone()); - helper.cursor(self.text(), self.caret().caret_position()) - }) - } - - fn current_line_height(&self, text: &Text) -> Option { - text.glyphs().and_then(|glyphs| { - let helper = TextGlyphsHelper::new(text.text.clone(), glyphs.clone()); - helper.line_height(self.text(), self.caret().caret_position()) - }) - } -} - -impl SelectableText for TextSelectable { - fn select_range(&self) -> Range { self.caret.select_range() } - fn text(&self) -> &CowArc { &self.text } - fn caret(&self) -> CaretState { self.caret } - fn set_caret(&mut self, caret: CaretState) { self.caret = caret; } +pub type TextSelectChangedEvent = CustomEvent; + +/// A Widget that extends [`Text`] to support text selection. +/// +/// # Example +/// ```no_run +/// use ribir::prelude::*; +/// fn_widget! { +/// @TextSelectable { +/// @{ "Hello world" } +/// } +/// } +/// App::run(w); +/// ``` +#[derive(Declare)] +pub struct TextSelectable { + #[declare(default)] + caret: CaretState, } impl TextSelectable { - fn reset(&mut self, text: &CowArc) { - self.text = text.clone(); - self.caret = CaretState::default(); - } -} - -pub(crate) fn bind_point_listener( - this: impl StateWriter + 'static, host: Widget, text: Reader, -) -> Widget { - fn_widget! { - let host = FatObj::new(host); - @ $host { - on_pointer_down: move |e| { - let mut this = $this.write(); - let position = e.position(); - if let Some(helper) = $text.glyphs() { - let end = helper.caret_position_from_pos(position.x, position.y); - let begin = if e.with_shift_key() { - match this.caret() { - CaretState::Caret(begin) | - CaretState::Select(begin, _) | - CaretState::Selecting(begin, _) => begin, - } - } else { - end - }; - this.set_caret(CaretState::Selecting(begin, end)); - } - }, - on_pointer_move: move |e| { - let mut this = $this.write(); - if let CaretState::Selecting(begin, _) = this.caret() { - if e.point_type == PointerType::Mouse - && e.mouse_buttons() == MouseButtons::PRIMARY { - if let Some(glyphs) = $text.glyphs() { - let position = e.position(); - let end = glyphs.caret_position_from_pos(position.x, position.y); - this.set_caret(CaretState::Selecting(begin, end)); - } - } - } - }, - on_pointer_up: move |_| { - let mut this = $this.write(); - if let CaretState::Selecting(begin, end) = this.caret() { - let caret = if begin == end { - CaretState::Caret(begin) - } else { - CaretState::Select(begin, end) - }; - - this.set_caret(caret); - } - }, - on_double_tap: move |e| { - if let Some(glyphs) = $text.glyphs() { - let position = e.position(); - let caret = glyphs.caret_position_from_pos(position.x, position.y); - let rg = select_word(&$text.text, caret.cluster); - $this.write().set_caret(CaretState::Select( - CaretPosition { cluster: rg.start, position: None }, - CaretPosition { cluster: rg.end, position: None } - )); - } - } + fn notify_changed(&self, track_id: TrackId, text: CowArc, wnd: &Window) { + if let Some(id) = track_id.get() { + wnd.bubble_custom_event(id, TextSelectChanged { text, caret: self.caret }); } } - .into_widget() } -impl ComposeChild<'static> for TextSelectable { - type Child = FatObj>; - fn compose_child(this: impl StateWriter, text: Self::Child) -> Widget<'static> { - let src = text.into_inner(); +#[derive(Template)] +pub enum TextSelectableTml { + Text(Stateful), + TextWidget(FatObj>), + Raw(TextInit), +} +impl<'c> ComposeChild<'c> for TextSelectable { + type Child = TextSelectableTml; + fn compose_child(this: impl StateWriter, child: Self::Child) -> Widget<'c> { fn_widget! { - let text = @ $src {}; - $this.silent().text = $text.text.clone(); - watch!($text.text.clone()) - .subscribe(move |v| { - if $this.text != $text.text { - $this.write().reset(&v); - } - }); - - let only_text = text.clone_reader(); - - let stack = @Stack { - fit: StackFit::Loose, - }; - - let high_light_rect = @ OnlySizedByParent { - @ SelectedHighLight { - rects: pipe! { $this.select_text_rect(&$text)} + let text = match child { + TextSelectableTml::Text(text) => { + text + } + TextSelectableTml::Raw(text) => { + @Text { text }.clone_writer() + } + TextSelectableTml::TextWidget(text) => { + text.clone_writer() } }; - let text_widget = text.into_widget(); - let text_widget = bind_point_listener( - this.clone_writer(), - text_widget, - only_text.clone_reader(), - ); - + let mut stack = @Stack {}; + let high_light_rect = @ { + high_light_widget(this.map_writer(|v| PartData::from_ref(&v.caret)), text.clone_watcher()) + }; @ $stack { tab_index: -1_i16, - on_blur: move |_| { $this.write().set_caret(CaretState::default()); }, - on_key_down: move |k| { - select_key_handle(&this, &$only_text, k); + on_key_down: { + let caret_writer = this.map_writer(|v| PartData::from_ref_mut(&mut v.caret)); + move |e| { + let changed = TextOnlySelectable { + text: $text.text.clone(), + caret: caret_writer.clone_writer(), + }.keys_select_handle(&$text, e); + if changed { + $this.notify_changed($stack.track_id(), $text.text.clone(), &e.window()); + } + } }, - @ $high_light_rect { } - @ $text_widget {} - } - } - .into_widget() - } -} -pub(crate) fn select_key_handle( - this: &impl StateWriter, text: &Text, event: &KeyboardEvent, -) { - let mut deal = false; - if event.with_command_key() { - deal = deal_with_command(this, event); - } - - if !deal { - deal_with_selection(this, text, event); - } -} - -fn deal_with_command( - this: &impl StateWriter, event: &KeyboardEvent, -) -> bool { - // use the physical key to make sure the keyboard with different - // layout use the same key as shortcut. - match event.key_code() { - PhysicalKey::Code(KeyCode::KeyC) => { - let text = this.read().selected_text(); - if !text.is_empty() { - let clipboard = AppCtx::clipboard(); - let _ = clipboard.borrow_mut().clear(); - let _ = clipboard.borrow_mut().write_text(&text); + @ { high_light_rect } + @ SelectRegion { + on_custom_event: { + let caret_writer = this.map_writer(|v| PartData::from_ref_mut(&mut v.caret)); + move |e: &mut SelectRegionEvent| { + let changed = TextOnlySelectable { + text: $text.text.clone(), + caret:caret_writer.clone_writer(), + }.select_region_handle(&$text, e); + if changed { + $this.notify_changed($stack.track_id(), $text.text.clone(), &e.window()); + } + } + }, + @ { text.clone_writer() } + } } } - PhysicalKey::Code(KeyCode::KeyA) => { - let len = this.read().text().len(); - this.write().set_caret(CaretState::Select( - CaretPosition { cluster: 0, position: None }, - CaretPosition { cluster: len, position: None }, - )); - } - _ => return false, + .into_widget() } - true } -fn is_move_by_word(event: &KeyboardEvent) -> bool { - #[cfg(target_os = "macos")] - return event.with_alt_key(); - #[cfg(not(target_os = "macos"))] - return event.with_ctrl_key(); +struct TextOnlySelectable { + text: CowArc, + caret: C, } -fn deal_with_selection( - this: &impl StateWriter, text: &Text, event: &KeyboardEvent, -) { - let Some(glyphs) = text.glyphs() else { return }; - let helper = TextGlyphsHelper::new(text.text.clone(), glyphs.clone()); +impl> SelectableText for TextOnlySelectable { + fn caret(&self) -> CaretState { *self.caret.read() } - let old_caret = this.read().caret(); - let text = this.read().text().clone(); - let new_caret_position = match event.key() { - VirtualKey::Named(NamedKey::ArrowLeft) => { - if is_move_by_word(event) { - let cluster = select_prev_word(&text, old_caret.cluster(), false).start; - Some(CaretPosition { cluster, position: None }) - } else if event.with_command_key() { - helper.line_begin(&text, old_caret.caret_position()) - } else { - helper.prev(&text, old_caret.caret_position()) - } - } - VirtualKey::Named(NamedKey::ArrowRight) => { - if is_move_by_word(event) { - let cluster = select_next_word(&text, old_caret.cluster(), true).end; - Some(CaretPosition { cluster, position: None }) - } else if event.with_command_key() { - helper.line_end(&text, old_caret.caret_position()) - } else { - helper.next(&text, old_caret.caret_position()) - } - } - VirtualKey::Named(NamedKey::ArrowUp) => helper.up(&text, old_caret.caret_position()), - VirtualKey::Named(NamedKey::ArrowDown) => helper.down(&text, old_caret.caret_position()), - VirtualKey::Named(NamedKey::Home) => helper.line_begin(&text, old_caret.caret_position()), - VirtualKey::Named(NamedKey::End) => helper.line_end(&text, old_caret.caret_position()), - _ => None, - }; + fn set_caret(&mut self, caret: CaretState) { *self.caret.write() = caret; } - if new_caret_position.is_some() { - if event.with_shift_key() { - this.write().set_caret(match old_caret { - CaretState::Caret(begin) - | CaretState::Select(begin, _) - | CaretState::Selecting(begin, _) => CaretState::Select(begin, new_caret_position.unwrap()), - }) - } else { - this - .write() - .set_caret(new_caret_position.unwrap().into()) - } - } + fn text(&self) -> CowArc { self.text.clone() } } diff --git a/widgets/src/layout.rs b/widgets/src/layout.rs index 5ec5cd79d..94dbc8c7b 100644 --- a/widgets/src/layout.rs +++ b/widgets/src/layout.rs @@ -21,6 +21,8 @@ impl Direction { pub fn is_vertical(&self) -> bool { matches!(self, Direction::Vertical) } } +pub mod text_clamp; +pub use text_clamp::*; pub mod flex; mod sized_box; pub use flex::*; diff --git a/widgets/src/layout/text_clamp.rs b/widgets/src/layout/text_clamp.rs new file mode 100644 index 000000000..2aee674b3 --- /dev/null +++ b/widgets/src/layout/text_clamp.rs @@ -0,0 +1,92 @@ +use ribir_core::{impl_compose_child_for_wrap_render, prelude::*, wrap_render::WrapRender}; + +/// A widget that constrains its children to a certain size, the size as +/// characters laid out to rows * columns based on text style metrics. +#[derive(Declare)] +pub struct TextClamp { + /// If rows is Some(rows), the height of the child will be constrained to + /// rows * text_style.line_height + /// Default is None, no additional constraints on height will be applied + #[declare(default)] + pub rows: Option, + + /// If columns is Some(cols), the width of the child will be constrained to + /// columns * text_style.font_size. + /// Default is None, no additional constraints on width will be applied + #[declare(default)] + pub cols: Option, +} + +impl_compose_child_for_wrap_render!(TextClamp); + +impl WrapRender for TextClamp { + fn perform_layout(&self, mut clamp: BoxClamp, host: &dyn Render, ctx: &mut LayoutCtx) -> Size { + let text_style = ctx.text_style(); + if let Some(rows) = self.rows { + let height = rows * text_style.line_height; + clamp = clamp.with_fixed_height(height.clamp(clamp.min.height, clamp.max.height)); + } + if let Some(cols) = self.cols { + let width = cols * text_style.font_size; + clamp = clamp.with_fixed_width(width.clamp(clamp.min.width, clamp.max.width)); + } + + host.perform_layout(clamp, ctx) + } +} + +#[cfg(test)] +mod tests { + use ribir_core::test_helper::*; + use ribir_dev_helper::*; + + use super::*; + const WND_SIZE: Size = Size::new(200., 200.); + + widget_layout_test!( + text_clamp_row_cols, + WidgetTester::new(fn_widget! { + @TextClamp { + rows: Some(1.5), + cols: Some(15.), + font_size: 12., + text_line_height: 16., + @Container { + size: WND_SIZE + } + } + }) + .with_wnd_size(WND_SIZE), + LayoutCase::default().with_size(Size::new(180., 24.)) + ); + + widget_layout_test!( + text_clamp_rows, + WidgetTester::new(fn_widget! { + @TextClamp { + rows: Some(1.5), + text_line_height: 16., + @Container { + size: WND_SIZE + } + } + }) + .with_wnd_size(WND_SIZE), + LayoutCase::default().with_size(Size::new(200., 24.)) + ); + + widget_layout_test!( + text_clamp_cols, + WidgetTester::new(fn_widget! { + @TextClamp { + cols: Some(15.5), + font_size: 12., + @Container { + size: WND_SIZE + } + } + }) + .with_wnd_size(WND_SIZE), + LayoutCase::default().with_size(Size::new(186., 200.)) + ); +} diff --git a/widgets/src/lib.rs b/widgets/src/lib.rs index 5db001e7a..9b3b29c11 100644 --- a/widgets/src/lib.rs +++ b/widgets/src/lib.rs @@ -14,6 +14,7 @@ pub mod path; pub mod progress; pub mod radio; pub mod scrollbar; +pub mod select_region; pub mod slider; pub mod tabs; pub mod text_field; @@ -23,6 +24,6 @@ pub mod prelude { pub use super::{ avatar::*, buttons::*, checkbox::*, common_widget::*, divider::*, grid_view::*, icon::*, input::*, label::*, layout::*, link::*, lists::*, path::*, progress::*, radio::*, scrollbar::*, - slider::*, tabs::*, text_field::*, transform_box::*, + select_region::*, slider::*, tabs::*, text_field::*, transform_box::*, }; } diff --git a/widgets/src/select_region.rs b/widgets/src/select_region.rs new file mode 100644 index 000000000..299f7a303 --- /dev/null +++ b/widgets/src/select_region.rs @@ -0,0 +1,80 @@ +use ribir_core::prelude::*; + +/// region select data +#[derive(Clone, Copy)] +pub enum SelectRegionData { + SelectRect { from: Point, to: Point }, + DoubleSelect(Point), + ShiftTo(Point), + SetTo(Point), +} + +/// region select event +pub type SelectRegionEvent = CustomEvent; + +/// A Widget that extends Widget to emit SelectRegionEvent +#[derive(Declare)] +pub struct SelectRegion {} + +fn notify_select_changed(wid: WidgetId, e: SelectRegionData, wnd: &Window) { + wnd.bubble_custom_event(wid, e); +} + +impl<'c> ComposeChild<'c> for SelectRegion { + type Child = Widget<'c>; + + fn compose_child(_: impl StateWriter, child: Self::Child) -> Widget<'c> { + fn_widget! { + let child = FatObj::new(child); + let grab_handle = Stateful::new(None); + let from = Stateful::new(None); + @ $child { + on_pointer_down: move |e| { + if e.with_shift_key() { + notify_select_changed( + e.current_target(), + SelectRegionData::ShiftTo(e.position()), + &e.window() + ); + } else { + notify_select_changed( + e.current_target(), + SelectRegionData::SetTo(e.position()), + &e.window() + ); + } + *$from.write() = Some(e.position()); + }, + on_pointer_move: move |e| { + if $grab_handle.is_none() + && $from.is_some() + && e.mouse_buttons() == MouseButtons::PRIMARY { + *$grab_handle.write() = Some(GrabPointer::grab(e.current_target(), &e.window())); + } + if $grab_handle.is_some() { + let from = $from.unwrap(); + notify_select_changed( + e.current_target(), + SelectRegionData::SelectRect { from, to: e.position() }, + &e.window() + ); + } else { + $from.write().take(); + } + }, + on_pointer_up: move |_| { + $from.write().take(); + $grab_handle.write().take(); + }, + on_double_tap: move |e| { + notify_select_changed( + e.current_target(), + SelectRegionData::DoubleSelect(e.position()), + &e.window() + ); + } + } + } + .into_widget() + } +} diff --git a/widgets/src/text_field.rs b/widgets/src/text_field.rs index 183cf3677..3c7c15ed2 100644 --- a/widgets/src/text_field.rs +++ b/widgets/src/text_field.rs @@ -22,10 +22,9 @@ pub struct TextFieldTml<'w> { /// a text field. label: Option