diff options
Diffstat (limited to 'widget')
40 files changed, 3385 insertions, 799 deletions
diff --git a/widget/Cargo.toml b/widget/Cargo.toml index 3c9f6a54..98a81145 100644 --- a/widget/Cargo.toml +++ b/widget/Cargo.toml @@ -22,8 +22,10 @@ lazy = ["ouroboros"] image = ["iced_renderer/image"] svg = ["iced_renderer/svg"] canvas = ["iced_renderer/geometry"] -qr_code = ["canvas", "qrcode"] +qr_code = ["canvas", "dep:qrcode"] wgpu = ["iced_renderer/wgpu"] +markdown = ["dep:pulldown-cmark", "dep:url"] +highlighter = ["dep:iced_highlighter"] advanced = [] [dependencies] @@ -31,6 +33,7 @@ iced_renderer.workspace = true iced_runtime.workspace = true num-traits.workspace = true +once_cell.workspace = true rustc-hash.workspace = true thiserror.workspace = true unicode-segmentation.workspace = true @@ -40,3 +43,12 @@ ouroboros.optional = true qrcode.workspace = true qrcode.optional = true + +pulldown-cmark.workspace = true +pulldown-cmark.optional = true + +iced_highlighter.workspace = true +iced_highlighter.optional = true + +url.workspace = true +url.optional = true diff --git a/widget/assets/iced-logo.svg b/widget/assets/iced-logo.svg new file mode 100644 index 00000000..459b7fbb --- /dev/null +++ b/widget/assets/iced-logo.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140" fill="none" version="1.1" viewBox="35 31 179 171"><rect x="42" y="31.001" width="169.9" height="169.9" rx="49.815" fill="url(#paint1_linear)"/><path d="m182.62 65.747-28.136 28.606-6.13-6.0291 28.136-28.606 6.13 6.0291zm-26.344 0.218-42.204 42.909-6.13-6.029 42.204-42.909 6.13 6.0291zm-61.648 23.913c5.3254-5.3831 10.65-10.765 21.569-21.867l6.13 6.0291c-10.927 11.11-16.258 16.498-21.587 21.885-4.4007 4.4488-8.8009 8.8968-16.359 16.573l31.977 8.358 25.968-26.402 6.13 6.0292-25.968 26.402 8.907 31.908 42.138-42.087 6.076 6.083-49.109 49.05-45.837-12.628-13.394-45.646 1.7714-1.801c10.928-11.111 16.258-16.499 21.588-21.886zm28.419 70.99-8.846-31.689-31.831-8.32 9.1945 31.335 31.482 8.674zm47.734-56.517 7.122-7.1221-6.08-6.0797-7.147 7.1474-30.171 30.674 6.13 6.029 30.146-30.649z" clip-rule="evenodd" fill="url(#paint2_linear)" fill-rule="evenodd"/><defs><filter id="filter0_f" x="55" y="47.001" width="144" height="168" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur" stdDeviation="2"/></filter><linearGradient id="paint0_linear" x1="127" x2="127" y1="51.001" y2="211" gradientUnits="userSpaceOnUse"><stop offset=".052083"/><stop stop-opacity=".08" offset="1"/></linearGradient><linearGradient id="paint1_linear" x1="212" x2="57.5" y1="31.001" y2="189" gradientUnits="userSpaceOnUse"><stop stop-color="#00A3FF" offset="0"/><stop stop-color="#30f" offset="1"/></linearGradient><linearGradient id="paint2_linear" x1="86.098" x2="206.01" y1="158.28" y2="35.327" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#fff" offset="1"/></linearGradient></defs></svg> diff --git a/widget/src/button.rs b/widget/src/button.rs index 5d446fea..eafa71b9 100644 --- a/widget/src/button.rs +++ b/widget/src/button.rs @@ -1,4 +1,5 @@ //! Allow your users to perform actions by pressing a button. +use crate::core::border::{self, Border}; use crate::core::event::{self, Event}; use crate::core::layout; use crate::core::mouse; @@ -9,8 +10,8 @@ use crate::core::touch; use crate::core::widget::tree::{self, Tree}; use crate::core::widget::Operation; use crate::core::{ - Background, Border, Clipboard, Color, Element, Layout, Length, Padding, - Rectangle, Shadow, Shell, Size, Theme, Vector, Widget, + Background, Clipboard, Color, Element, Layout, Length, Padding, Rectangle, + Shadow, Shell, Size, Theme, Vector, Widget, }; /// A generic widget that produces a message when pressed. @@ -52,7 +53,7 @@ where Theme: Catalog, { content: Element<'a, Message, Theme, Renderer>, - on_press: Option<Message>, + on_press: Option<OnPress<'a, Message>>, width: Length, height: Length, padding: Padding, @@ -60,6 +61,20 @@ where class: Theme::Class<'a>, } +enum OnPress<'a, Message> { + Direct(Message), + Closure(Box<dyn Fn() -> Message + 'a>), +} + +impl<'a, Message: Clone> OnPress<'a, Message> { + fn get(&self) -> Message { + match self { + OnPress::Direct(message) => message.clone(), + OnPress::Closure(f) => f(), + } + } +} + impl<'a, Message, Theme, Renderer> Button<'a, Message, Theme, Renderer> where Renderer: crate::core::Renderer, @@ -105,7 +120,23 @@ where /// /// Unless `on_press` is called, the [`Button`] will be disabled. pub fn on_press(mut self, on_press: Message) -> Self { - self.on_press = Some(on_press); + self.on_press = Some(OnPress::Direct(on_press)); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed. + /// + /// This is analogous to [`Button::on_press`], but using a closure to produce + /// the message. + /// + /// This closure will only be called when the [`Button`] is actually pressed and, + /// therefore, this method is useful to reduce overhead if creating the resulting + /// message is slow. + pub fn on_press_with( + mut self, + on_press: impl Fn() -> Message + 'a, + ) -> Self { + self.on_press = Some(OnPress::Closure(Box::new(on_press))); self } @@ -114,7 +145,7 @@ where /// /// If `None`, the [`Button`] will be disabled. pub fn on_press_maybe(mut self, on_press: Option<Message>) -> Self { - self.on_press = on_press; + self.on_press = on_press.map(OnPress::Direct); self } @@ -205,7 +236,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { operation.container(None, layout.bounds(), &mut |operation| { self.content.as_widget().operate( @@ -258,7 +289,8 @@ where } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) => { - if let Some(on_press) = self.on_press.clone() { + if let Some(on_press) = self.on_press.as_ref().map(OnPress::get) + { let state = tree.state.downcast_mut::<State>(); if state.is_pressed { @@ -560,7 +592,7 @@ fn styled(pair: palette::Pair) -> Style { Style { background: Some(Background::Color(pair.color)), text_color: pair.text, - border: Border::rounded(2), + border: border::rounded(2), ..Style::default() } } diff --git a/widget/src/canvas.rs b/widget/src/canvas.rs index 73cef087..185fa082 100644 --- a/widget/src/canvas.rs +++ b/widget/src/canvas.rs @@ -8,8 +8,8 @@ pub use program::Program; pub use crate::graphics::cache::Group; pub use crate::graphics::geometry::{ - fill, gradient, path, stroke, Fill, Gradient, LineCap, LineDash, LineJoin, - Path, Stroke, Style, Text, + fill, gradient, path, stroke, Fill, Gradient, Image, LineCap, LineDash, + LineJoin, Path, Stroke, Style, Text, }; use crate::core; diff --git a/widget/src/checkbox.rs b/widget/src/checkbox.rs index 225c316d..32db5090 100644 --- a/widget/src/checkbox.rs +++ b/widget/src/checkbox.rs @@ -50,6 +50,7 @@ pub struct Checkbox< text_size: Option<Pixels>, text_line_height: text::LineHeight, text_shaping: text::Shaping, + text_wrapping: text::Wrapping, font: Option<Renderer::Font>, icon: Icon<Renderer::Font>, class: Theme::Class<'a>, @@ -81,7 +82,8 @@ where spacing: Self::DEFAULT_SPACING, text_size: None, text_line_height: text::LineHeight::default(), - text_shaping: text::Shaping::Basic, + text_shaping: text::Shaping::default(), + text_wrapping: text::Wrapping::default(), font: None, icon: Icon { font: Renderer::ICON_FONT, @@ -158,6 +160,12 @@ where self } + /// Sets the [`text::Wrapping`] strategy of the [`Checkbox`]. + pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self { + self.text_wrapping = wrapping; + self + } + /// Sets the [`Renderer::Font`] of the text of the [`Checkbox`]. /// /// [`Renderer::Font`]: crate::core::text::Renderer @@ -240,6 +248,7 @@ where alignment::Horizontal::Left, alignment::Vertical::Top, self.text_shaping, + self.text_wrapping, ) }, ) @@ -348,6 +357,7 @@ where horizontal_alignment: alignment::Horizontal::Center, vertical_alignment: alignment::Vertical::Center, shaping: *shaping, + wrapping: text::Wrapping::default(), }, bounds.center(), style.icon_color, @@ -358,12 +368,14 @@ where { let label_layout = children.next().unwrap(); + let state: &widget::text::State<Renderer::Paragraph> = + tree.state.downcast_ref(); crate::text::draw( renderer, defaults, label_layout, - tree.state.downcast_ref(), + state.0.raw(), crate::text::Style { color: style.text_color, }, diff --git a/widget/src/column.rs b/widget/src/column.rs index 0b81c545..d3ea4cf7 100644 --- a/widget/src/column.rs +++ b/widget/src/column.rs @@ -1,4 +1,5 @@ //! Distribute content vertically. +use crate::core::alignment::{self, Alignment}; use crate::core::event::{self, Event}; use crate::core::layout; use crate::core::mouse; @@ -6,8 +7,8 @@ use crate::core::overlay; use crate::core::renderer; use crate::core::widget::{Operation, Tree}; use crate::core::{ - Alignment, Clipboard, Element, Layout, Length, Padding, Pixels, Rectangle, - Shell, Size, Vector, Widget, + Clipboard, Element, Layout, Length, Padding, Pixels, Rectangle, Shell, + Size, Vector, Widget, }; /// A container that distributes its contents vertically. @@ -19,7 +20,7 @@ pub struct Column<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> width: Length, height: Length, max_width: f32, - align_items: Alignment, + align: Alignment, clip: bool, children: Vec<Element<'a, Message, Theme, Renderer>>, } @@ -63,7 +64,7 @@ where width: Length::Shrink, height: Length::Shrink, max_width: f32::INFINITY, - align_items: Alignment::Start, + align: Alignment::Start, clip: false, children, } @@ -104,8 +105,8 @@ where } /// Sets the horizontal alignment of the contents of the [`Column`] . - pub fn align_items(mut self, align: Alignment) -> Self { - self.align_items = align; + pub fn align_x(mut self, align: impl Into<alignment::Horizontal>) -> Self { + self.align = Alignment::from(align.into()); self } @@ -210,7 +211,7 @@ where self.height, self.padding, self.spacing, - self.align_items, + self.align, &self.children, &mut tree.children, ) @@ -221,7 +222,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { operation.container(None, layout.bounds(), &mut |operation| { self.children diff --git a/widget/src/combo_box.rs b/widget/src/combo_box.rs index 253850df..62785b2c 100644 --- a/widget/src/combo_box.rs +++ b/widget/src/combo_box.rs @@ -208,12 +208,14 @@ where /// The local state of a [`ComboBox`]. #[derive(Debug, Clone)] -pub struct State<T>(RefCell<Inner<T>>); +pub struct State<T> { + options: Vec<T>, + inner: RefCell<Inner<T>>, +} #[derive(Debug, Clone)] struct Inner<T> { value: String, - options: Vec<T>, option_matchers: Vec<String>, filtered_options: Filtered<T>, } @@ -247,39 +249,58 @@ where .collect(), ); - Self(RefCell::new(Inner { - value, + Self { options, - option_matchers, - filtered_options, - })) + inner: RefCell::new(Inner { + value, + option_matchers, + filtered_options, + }), + } + } + + /// Returns the options of the [`State`]. + /// + /// These are the options provided when the [`State`] + /// was constructed with [`State::new`]. + pub fn options(&self) -> &[T] { + &self.options } fn value(&self) -> String { - let inner = self.0.borrow(); + let inner = self.inner.borrow(); inner.value.clone() } fn with_inner<O>(&self, f: impl FnOnce(&Inner<T>) -> O) -> O { - let inner = self.0.borrow(); + let inner = self.inner.borrow(); f(&inner) } fn with_inner_mut(&self, f: impl FnOnce(&mut Inner<T>)) { - let mut inner = self.0.borrow_mut(); + let mut inner = self.inner.borrow_mut(); f(&mut inner); } fn sync_filtered_options(&self, options: &mut Filtered<T>) { - let inner = self.0.borrow(); + let inner = self.inner.borrow(); inner.filtered_options.sync(options); } } +impl<T> Default for State<T> +where + T: Display + Clone, +{ + fn default() -> Self { + Self::new(Vec::new()) + } +} + impl<T> Filtered<T> where T: Clone, @@ -431,7 +452,7 @@ where state.filtered_options.update( search( - &state.options, + &self.state.options, &state.option_matchers, &state.value, ) @@ -580,7 +601,7 @@ where if let Some(selection) = menu.new_selection.take() { // Clear the value and reset the options and menu state.value = String::new(); - state.filtered_options.update(state.options.clone()); + state.filtered_options.update(self.state.options.clone()); menu.menu = menu::State::default(); // Notify the selection diff --git a/widget/src/container.rs b/widget/src/container.rs index e917471f..3b794099 100644 --- a/widget/src/container.rs +++ b/widget/src/container.rs @@ -1,5 +1,6 @@ //! Decorate content and apply alignment. use crate::core::alignment::{self, Alignment}; +use crate::core::border::{self, Border}; use crate::core::event::{self, Event}; use crate::core::gradient::{self, Gradient}; use crate::core::layout; @@ -9,11 +10,11 @@ use crate::core::renderer; use crate::core::widget::tree::{self, Tree}; use crate::core::widget::{self, Operation}; use crate::core::{ - self, Background, Border, Clipboard, Color, Element, Layout, Length, + self, color, Background, Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shadow, Shell, Size, Theme, Vector, Widget, }; -use crate::runtime::Task; +use crate::runtime::task::{self, Task}; /// An element decorating some content. /// @@ -92,46 +93,6 @@ where self } - /// Sets the [`Container`] to fill the available space in the horizontal axis. - /// - /// This can be useful to quickly position content when chained with - /// alignment functions—like [`center_x`]. - /// - /// Calling this method is equivalent to calling [`width`] with a - /// [`Length::Fill`]. - /// - /// [`center_x`]: Self::center_x - /// [`width`]: Self::width - pub fn fill_x(self) -> Self { - self.width(Length::Fill) - } - - /// Sets the [`Container`] to fill the available space in the vetical axis. - /// - /// This can be useful to quickly position content when chained with - /// alignment functions—like [`center_y`]. - /// - /// Calling this method is equivalent to calling [`height`] with a - /// [`Length::Fill`]. - /// - /// [`center_y`]: Self::center_x - /// [`height`]: Self::height - pub fn fill_y(self) -> Self { - self.height(Length::Fill) - } - - /// Sets the [`Container`] to fill all the available space. - /// - /// Calling this method is equivalent to chaining [`fill_x`] and - /// [`fill_y`]. - /// - /// [`center`]: Self::center - /// [`fill_x`]: Self::fill_x - /// [`fill_y`]: Self::fill_y - pub fn fill(self) -> Self { - self.width(Length::Fill).height(Length::Fill) - } - /// Sets the maximum width of the [`Container`]. pub fn max_width(mut self, max_width: impl Into<Pixels>) -> Self { self.max_width = max_width.into().0; @@ -144,18 +105,6 @@ where self } - /// Sets the content alignment for the horizontal axis of the [`Container`]. - pub fn align_x(mut self, alignment: alignment::Horizontal) -> Self { - self.horizontal_alignment = alignment; - self - } - - /// Sets the content alignment for the vertical axis of the [`Container`]. - pub fn align_y(mut self, alignment: alignment::Vertical) -> Self { - self.vertical_alignment = alignment; - self - } - /// Sets the width of the [`Container`] and centers its contents horizontally. pub fn center_x(self, width: impl Into<Length>) -> Self { self.width(width).align_x(alignment::Horizontal::Center) @@ -179,6 +128,44 @@ where self.center_x(length).center_y(length) } + /// Aligns the contents of the [`Container`] to the left. + pub fn align_left(self, width: impl Into<Length>) -> Self { + self.width(width).align_x(alignment::Horizontal::Left) + } + + /// Aligns the contents of the [`Container`] to the right. + pub fn align_right(self, width: impl Into<Length>) -> Self { + self.width(width).align_x(alignment::Horizontal::Right) + } + + /// Aligns the contents of the [`Container`] to the top. + pub fn align_top(self, height: impl Into<Length>) -> Self { + self.height(height).align_y(alignment::Vertical::Top) + } + + /// Aligns the contents of the [`Container`] to the bottom. + pub fn align_bottom(self, height: impl Into<Length>) -> Self { + self.height(height).align_y(alignment::Vertical::Bottom) + } + + /// Sets the content alignment for the horizontal axis of the [`Container`]. + pub fn align_x( + mut self, + alignment: impl Into<alignment::Horizontal>, + ) -> Self { + self.horizontal_alignment = alignment.into(); + self + } + + /// Sets the content alignment for the vertical axis of the [`Container`]. + pub fn align_y( + mut self, + alignment: impl Into<alignment::Vertical>, + ) -> Self { + self.vertical_alignment = alignment.into(); + self + } + /// Sets whether the contents of the [`Container`] should be clipped on /// overflow. pub fn clip(mut self, clip: bool) -> Self { @@ -197,7 +184,6 @@ where } /// Sets the style class of the [`Container`]. - #[cfg(feature = "advanced")] #[must_use] pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self { self.class = class.into(); @@ -258,7 +244,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { operation.container( self.id.as_ref().map(|id| &id.0), @@ -473,6 +459,7 @@ pub fn visible_bounds(id: Id) -> Task<Option<Rectangle>> { _state: &mut dyn widget::operation::Scrollable, _id: Option<&widget::Id>, bounds: Rectangle, + _content_bounds: Rectangle, translation: Vector, ) { match self.scrollables.last() { @@ -538,7 +525,7 @@ pub fn visible_bounds(id: Id) -> Task<Option<Rectangle>> { } } - Task::widget(VisibleBounds { + task::widget(VisibleBounds { target: id.into(), depth: 0, scrollables: Vec::new(), @@ -560,46 +547,54 @@ pub struct Style { } impl Style { - /// Updates the border of the [`Style`] with the given [`Color`] and `width`. - pub fn with_border( - self, - color: impl Into<Color>, - width: impl Into<Pixels>, - ) -> Self { + /// Updates the text color of the [`Style`]. + pub fn color(self, color: impl Into<Color>) -> Self { Self { - border: Border { - color: color.into(), - width: width.into().0, - ..Border::default() - }, + text_color: Some(color.into()), + ..self + } + } + + /// Updates the border of the [`Style`]. + pub fn border(self, border: impl Into<Border>) -> Self { + Self { + border: border.into(), ..self } } /// Updates the background of the [`Style`]. - pub fn with_background(self, background: impl Into<Background>) -> Self { + pub fn background(self, background: impl Into<Background>) -> Self { Self { background: Some(background.into()), ..self } } + + /// Updates the shadow of the [`Style`]. + pub fn shadow(self, shadow: impl Into<Shadow>) -> Self { + Self { + shadow: shadow.into(), + ..self + } + } } impl From<Color> for Style { fn from(color: Color) -> Self { - Self::default().with_background(color) + Self::default().background(color) } } impl From<Gradient> for Style { fn from(gradient: Gradient) -> Self { - Self::default().with_background(gradient) + Self::default().background(gradient) } } impl From<gradient::Linear> for Style { fn from(gradient: gradient::Linear) -> Self { - Self::default().with_background(gradient) + Self::default().background(gradient) } } @@ -618,6 +613,12 @@ pub trait Catalog { /// A styling function for a [`Container`]. pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>; +impl<'a, Theme> From<Style> for StyleFn<'a, Theme> { + fn from(style: Style) -> Self { + Box::new(move |_theme| style) + } +} + impl Catalog for Theme { type Class<'a> = StyleFn<'a, Self>; @@ -635,13 +636,18 @@ pub fn transparent<Theme>(_theme: &Theme) -> Style { Style::default() } +/// A [`Container`] with the given [`Background`]. +pub fn background(background: impl Into<Background>) -> Style { + Style::default().background(background) +} + /// A rounded [`Container`] with a background. pub fn rounded_box(theme: &Theme) -> Style { let palette = theme.extended_palette(); Style { background: Some(palette.background.weak.color.into()), - border: Border::rounded(2), + border: border::rounded(2), ..Style::default() } } @@ -660,3 +666,13 @@ pub fn bordered_box(theme: &Theme) -> Style { ..Style::default() } } + +/// A [`Container`] with a dark background and white text. +pub fn dark(_theme: &Theme) -> Style { + Style { + background: Some(color!(0x111111).into()), + text_color: Some(Color::WHITE), + border: border::rounded(2), + ..Style::default() + } +} diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index 62343a55..51978823 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -4,7 +4,8 @@ use crate::checkbox::{self, Checkbox}; use crate::combo_box::{self, ComboBox}; use crate::container::{self, Container}; use crate::core; -use crate::core::widget::operation; +use crate::core::widget::operation::{self, Operation}; +use crate::core::window; use crate::core::{Element, Length, Pixels, Widget}; use crate::keyed; use crate::overlay; @@ -12,7 +13,8 @@ use crate::pick_list::{self, PickList}; use crate::progress_bar::{self, ProgressBar}; use crate::radio::{self, Radio}; use crate::rule::{self, Rule}; -use crate::runtime::{Action, Task}; +use crate::runtime::task::{self, Task}; +use crate::runtime::Action; use crate::scrollable::{self, Scrollable}; use crate::slider::{self, Slider}; use crate::text::{self, Text}; @@ -111,6 +113,19 @@ macro_rules! text { }; } +/// Creates some [`Rich`] text with the given spans. +/// +/// [`Rich`]: text::Rich +#[macro_export] +macro_rules! rich_text { + () => ( + $crate::Column::new() + ); + ($($x:expr),+ $(,)?) => ( + $crate::text::Rich::from_iter([$($crate::text::Span::from($x)),+]) + ); +} + /// Creates a new [`Container`] with the provided content. /// /// [`Container`]: crate::Container @@ -275,7 +290,7 @@ where state: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn operation::Operation<()>, + operation: &mut dyn operation::Operation, ) { self.content .as_widget() @@ -383,6 +398,7 @@ where struct Hover<'a, Message, Theme, Renderer> { base: Element<'a, Message, Theme, Renderer>, top: Element<'a, Message, Theme, Renderer>, + is_top_focused: bool, is_top_overlay_active: bool, } @@ -458,7 +474,9 @@ where viewport, ); - if cursor.is_over(layout.bounds()) || self.is_top_overlay_active + if cursor.is_over(layout.bounds()) + || self.is_top_focused + || self.is_top_overlay_active { let (top_layout, top_tree) = children.next().unwrap(); @@ -477,7 +495,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn operation::Operation<()>, + operation: &mut dyn operation::Operation, ) { let children = [&self.base, &self.top] .into_iter() @@ -501,6 +519,24 @@ where ) -> event::Status { let mut children = layout.children().zip(&mut tree.children); let (base_layout, base_tree) = children.next().unwrap(); + let (top_layout, top_tree) = children.next().unwrap(); + + if matches!(event, Event::Window(window::Event::RedrawRequested(_))) + { + let mut count_focused = operation::focusable::count(); + + self.top.as_widget_mut().operate( + top_tree, + top_layout, + renderer, + &mut operation::black_box(&mut count_focused), + ); + + self.is_top_focused = match count_focused.finish() { + operation::Outcome::Some(count) => count.focused.is_some(), + _ => false, + }; + } let top_status = if matches!( event, @@ -509,9 +545,9 @@ where | mouse::Event::ButtonReleased(_) ) ) || cursor.is_over(layout.bounds()) + || self.is_top_focused + || self.is_top_overlay_active { - let (top_layout, top_tree) = children.next().unwrap(); - self.top.as_widget_mut().on_event( top_tree, event.clone(), @@ -597,6 +633,7 @@ where Element::new(Hover { base: base.into(), top: top.into(), + is_top_focused: false, is_top_overlay_active: false, }) } @@ -645,8 +682,6 @@ where } /// Creates a new [`Text`] widget with the provided content. -/// -/// [`Text`]: core::widget::Text pub fn text<'a, Theme, Renderer>( text: impl text::IntoFragment<'a>, ) -> Text<'a, Theme, Renderer> @@ -658,8 +693,6 @@ where } /// Creates a new [`Text`] widget that displays the provided value. -/// -/// [`Text`]: core::widget::Text pub fn value<'a, Theme, Renderer>( value: impl ToString, ) -> Text<'a, Theme, Renderer> @@ -670,6 +703,34 @@ where Text::new(value.to_string()) } +/// Creates a new [`Rich`] text widget with the provided spans. +/// +/// [`Rich`]: text::Rich +pub fn rich_text<'a, Link, Theme, Renderer>( + spans: impl AsRef<[text::Span<'a, Link, Renderer::Font>]> + 'a, +) -> text::Rich<'a, Link, Theme, Renderer> +where + Link: Clone + 'static, + Theme: text::Catalog + 'a, + Renderer: core::text::Renderer, + Renderer::Font: 'a, +{ + text::Rich::with_spans(spans) +} + +/// Creates a new [`Span`] of text with the provided content. +/// +/// [`Span`]: text::Span +pub fn span<'a, Link, Font>( + text: impl text::IntoFragment<'a>, +) -> text::Span<'a, Link, Font> { + text::Span::new(text) +} + +#[cfg(feature = "markdown")] +#[doc(inline)] +pub use crate::markdown::view as markdown; + /// Creates a new [`Checkbox`]. /// /// [`Checkbox`]: crate::Checkbox @@ -706,15 +767,13 @@ where /// /// [`Toggler`]: crate::Toggler pub fn toggler<'a, Message, Theme, Renderer>( - label: impl Into<Option<String>>, is_checked: bool, - f: impl Fn(bool) -> Message + 'a, ) -> Toggler<'a, Message, Theme, Renderer> where Theme: toggler::Catalog + 'a, Renderer: core::text::Renderer, { - Toggler::new(label, is_checked, f) + Toggler::new(is_checked) } /// Creates a new [`TextInput`]. @@ -889,6 +948,41 @@ where crate::Svg::new(handle) } +/// Creates an [`Element`] that displays the iced logo with the given `text_size`. +/// +/// Useful for showing some love to your favorite GUI library in your "About" screen, +/// for instance. +#[cfg(feature = "svg")] +pub fn iced<'a, Message, Theme, Renderer>( + text_size: impl Into<Pixels>, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Renderer: core::Renderer + + core::text::Renderer<Font = core::Font> + + core::svg::Renderer + + 'a, + Theme: text::Catalog + crate::svg::Catalog + 'a, +{ + use crate::core::{Alignment, Font}; + use crate::svg; + use once_cell::sync::Lazy; + + static LOGO: Lazy<svg::Handle> = Lazy::new(|| { + svg::Handle::from_memory(include_bytes!("../assets/iced-logo.svg")) + }); + + let text_size = text_size.into(); + + row![ + svg(LOGO.clone()).width(text_size * 1.3), + text("iced").size(text_size).font(Font::MONOSPACE) + ] + .spacing(text_size.0 / 3.0) + .align_y(Alignment::Center) + .into() +} + /// Creates a new [`Canvas`]. /// /// [`Canvas`]: crate::Canvas @@ -930,12 +1024,12 @@ where /// Focuses the previous focusable widget. pub fn focus_previous<T>() -> Task<T> { - Task::effect(Action::widget(operation::focusable::focus_previous())) + task::effect(Action::widget(operation::focusable::focus_previous())) } /// Focuses the next focusable widget. pub fn focus_next<T>() -> Task<T> { - Task::effect(Action::widget(operation::focusable::focus_next())) + task::effect(Action::widget(operation::focusable::focus_next())) } /// A container intercepting mouse events. diff --git a/widget/src/image.rs b/widget/src/image.rs index 80e17263..e04f2d6f 100644 --- a/widget/src/image.rs +++ b/widget/src/image.rs @@ -43,7 +43,7 @@ pub struct Image<Handle> { impl<Handle> Image<Handle> { /// Creates a new [`Image`] with the given path. - pub fn new<T: Into<Handle>>(handle: T) -> Self { + pub fn new(handle: impl Into<Handle>) -> Self { Image { handle: handle.into(), width: Length::Shrink, @@ -181,11 +181,14 @@ pub fn draw<Renderer, Handle>( let render = |renderer: &mut Renderer| { renderer.draw_image( - handle.clone(), - filter_method, + image::Image { + handle: handle.clone(), + filter_method, + rotation: rotation.radians(), + opacity, + snap: true, + }, drawing_bounds, - rotation.radians(), - opacity, ); }; diff --git a/widget/src/image/viewer.rs b/widget/src/image/viewer.rs index b8b69b60..b1aad22c 100644 --- a/widget/src/image/viewer.rs +++ b/widget/src/image/viewer.rs @@ -6,8 +6,8 @@ use crate::core::mouse; use crate::core::renderer; use crate::core::widget::tree::{self, Tree}; use crate::core::{ - Clipboard, ContentFit, Element, Layout, Length, Pixels, Point, Radians, - Rectangle, Shell, Size, Vector, Widget, + Clipboard, ContentFit, Element, Image, Layout, Length, Pixels, Point, + Radians, Rectangle, Shell, Size, Vector, Widget, }; /// A frame that displays an image with the ability to zoom in/out and pan. @@ -349,11 +349,14 @@ where let render = |renderer: &mut Renderer| { renderer.with_translation(translation, |renderer| { renderer.draw_image( - self.handle.clone(), - self.filter_method, + Image { + handle: self.handle.clone(), + filter_method: self.filter_method, + rotation: Radians(0.0), + opacity: 1.0, + snap: true, + }, drawing_bounds, - Radians(0.0), - 1.0, ); }); }; diff --git a/widget/src/keyed/column.rs b/widget/src/keyed/column.rs index 69991d1f..2c56c605 100644 --- a/widget/src/keyed/column.rs +++ b/widget/src/keyed/column.rs @@ -265,7 +265,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { operation.container(None, layout.bounds(), &mut |operation| { self.children diff --git a/widget/src/lazy.rs b/widget/src/lazy.rs index 606da22d..221f9de3 100644 --- a/widget/src/lazy.rs +++ b/widget/src/lazy.rs @@ -4,6 +4,7 @@ pub(crate) mod helpers; pub mod component; pub mod responsive; +#[allow(deprecated)] pub use component::Component; pub use responsive::Responsive; @@ -29,6 +30,7 @@ use std::hash::{Hash, Hasher as H}; use std::rc::Rc; /// A widget that only rebuilds its contents when necessary. +#[cfg(feature = "lazy")] #[allow(missing_debug_implementations)] pub struct Lazy<'a, Message, Theme, Renderer, Dependency, View> { dependency: Dependency, @@ -182,7 +184,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn widget::Operation<()>, + operation: &mut dyn widget::Operation, ) { self.with_element(|element| { element.as_widget().operate( diff --git a/widget/src/lazy/component.rs b/widget/src/lazy/component.rs index f079c0df..659bc476 100644 --- a/widget/src/lazy/component.rs +++ b/widget/src/lazy/component.rs @@ -1,4 +1,5 @@ //! Build and reuse custom widgets using The Elm Architecture. +#![allow(deprecated)] use crate::core::event; use crate::core::layout::{self, Layout}; use crate::core::mouse; @@ -30,6 +31,12 @@ use std::rc::Rc; /// /// Additionally, a [`Component`] is capable of producing a `Message` to notify /// the parent application of any relevant interactions. +#[cfg(feature = "lazy")] +#[deprecated( + since = "0.13.0", + note = "components introduce encapsulated state and hamper the use of a single source of truth. \ + Instead, leverage the Elm Architecture directly, or implement a custom widget" +)] pub trait Component<Message, Theme = crate::Theme, Renderer = crate::Renderer> { /// The internal state of this [`Component`]. type State: Default; @@ -59,7 +66,7 @@ pub trait Component<Message, Theme = crate::Theme, Renderer = crate::Renderer> { fn operate( &self, _state: &mut Self::State, - _operation: &mut dyn widget::Operation<()>, + _operation: &mut dyn widget::Operation, ) { } @@ -172,7 +179,7 @@ where fn rebuild_element_with_operation( &self, - operation: &mut dyn widget::Operation<()>, + operation: &mut dyn widget::Operation, ) { let heads = self.state.borrow_mut().take().unwrap().into_heads(); @@ -358,7 +365,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn widget::Operation<()>, + operation: &mut dyn widget::Operation, ) { self.rebuild_element_with_operation(operation); diff --git a/widget/src/lazy/helpers.rs b/widget/src/lazy/helpers.rs index 4d0776ca..52e690ff 100644 --- a/widget/src/lazy/helpers.rs +++ b/widget/src/lazy/helpers.rs @@ -1,9 +1,11 @@ use crate::core::{self, Element, Size}; -use crate::lazy::component::{self, Component}; -use crate::lazy::{Lazy, Responsive}; +use crate::lazy::component; use std::hash::Hash; +#[allow(deprecated)] +pub use crate::lazy::{Component, Lazy, Responsive}; + /// Creates a new [`Lazy`] widget with the given data `Dependency` and a /// closure that can turn this data into a widget tree. #[cfg(feature = "lazy")] @@ -21,6 +23,12 @@ where /// Turns an implementor of [`Component`] into an [`Element`] that can be /// embedded in any application. #[cfg(feature = "lazy")] +#[deprecated( + since = "0.13.0", + note = "components introduce encapsulated state and hamper the use of a single source of truth. \ + Instead, leverage the Elm Architecture directly, or implement a custom widget" +)] +#[allow(deprecated)] pub fn component<'a, C, Message, Theme, Renderer>( component: C, ) -> Element<'a, Message, Theme, Renderer> diff --git a/widget/src/lazy/responsive.rs b/widget/src/lazy/responsive.rs index 27f52617..dbf281f3 100644 --- a/widget/src/lazy/responsive.rs +++ b/widget/src/lazy/responsive.rs @@ -21,6 +21,7 @@ use std::ops::Deref; /// /// A [`Responsive`] widget will always try to fill all the available space of /// its parent. +#[cfg(feature = "lazy")] #[allow(missing_debug_implementations)] pub struct Responsive< 'a, @@ -161,7 +162,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn widget::Operation<()>, + operation: &mut dyn widget::Operation, ) { let state = tree.state.downcast_mut::<State>(); let mut content = self.content.borrow_mut(); diff --git a/widget/src/lib.rs b/widget/src/lib.rs index 00e9aaa4..a68720d6 100644 --- a/widget/src/lib.rs +++ b/widget/src/lib.rs @@ -43,9 +43,6 @@ pub use helpers::*; mod lazy; #[cfg(feature = "lazy")] -pub use crate::lazy::{Component, Lazy, Responsive}; - -#[cfg(feature = "lazy")] pub use crate::lazy::helpers::*; #[doc(no_inline)] @@ -130,5 +127,8 @@ pub mod qr_code; #[doc(no_inline)] pub use qr_code::QRCode; +#[cfg(feature = "markdown")] +pub mod markdown; + pub use crate::core::theme::{self, Theme}; pub use renderer::Renderer; diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs new file mode 100644 index 00000000..fa4ee6bf --- /dev/null +++ b/widget/src/markdown.rs @@ -0,0 +1,587 @@ +//! Parse and display Markdown. +//! +//! You can enable the `highlighter` feature for syntax highligting +//! in code blocks. +//! +//! Only the variants of [`Item`] are currently supported. +use crate::core::border; +use crate::core::font::{self, Font}; +use crate::core::padding; +use crate::core::theme; +use crate::core::{ + self, color, Color, Element, Length, Padding, Pixels, Theme, +}; +use crate::{column, container, rich_text, row, scrollable, span, text}; + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +pub use core::text::Highlight; +pub use pulldown_cmark::HeadingLevel; +pub use url::Url; + +/// A Markdown item. +#[derive(Debug, Clone)] +pub enum Item { + /// A heading. + Heading(pulldown_cmark::HeadingLevel, Text), + /// A paragraph. + Paragraph(Text), + /// A code block. + /// + /// You can enable the `highlighter` feature for syntax highligting. + CodeBlock(Text), + /// A list. + List { + /// The first number of the list, if it is ordered. + start: Option<u64>, + /// The items of the list. + items: Vec<Vec<Item>>, + }, +} + +/// A bunch of parsed Markdown text. +#[derive(Debug, Clone)] +pub struct Text { + spans: Vec<Span>, + last_style: Cell<Option<Style>>, + last_styled_spans: RefCell<Rc<[text::Span<'static, Url>]>>, +} + +impl Text { + fn new(spans: Vec<Span>) -> Self { + Self { + spans, + last_style: Cell::default(), + last_styled_spans: RefCell::default(), + } + } + + /// Returns the [`rich_text()`] spans ready to be used for the given style. + /// + /// This method performs caching for you. It will only reallocate if the [`Style`] + /// provided changes. + pub fn spans(&self, style: Style) -> Rc<[text::Span<'static, Url>]> { + if Some(style) != self.last_style.get() { + *self.last_styled_spans.borrow_mut() = + self.spans.iter().map(|span| span.view(&style)).collect(); + + self.last_style.set(Some(style)); + } + + self.last_styled_spans.borrow().clone() + } +} + +#[derive(Debug, Clone)] +enum Span { + Standard { + text: String, + strikethrough: bool, + link: Option<Url>, + strong: bool, + emphasis: bool, + code: bool, + }, + #[cfg(feature = "highlighter")] + Highlight { + text: String, + color: Option<Color>, + font: Option<Font>, + }, +} + +impl Span { + fn view(&self, style: &Style) -> text::Span<'static, Url> { + match self { + Span::Standard { + text, + strikethrough, + link, + strong, + emphasis, + code, + } => { + let span = span(text.clone()).strikethrough(*strikethrough); + + let span = if *code { + span.font(Font::MONOSPACE) + .color(style.inline_code_color) + .background(style.inline_code_highlight.background) + .border(style.inline_code_highlight.border) + .padding(style.inline_code_padding) + } else if *strong || *emphasis { + span.font(Font { + weight: if *strong { + font::Weight::Bold + } else { + font::Weight::Normal + }, + style: if *emphasis { + font::Style::Italic + } else { + font::Style::Normal + }, + ..Font::default() + }) + } else { + span + }; + + let span = if let Some(link) = link.as_ref() { + span.color(style.link_color).link(link.clone()) + } else { + span + }; + + span + } + #[cfg(feature = "highlighter")] + Span::Highlight { text, color, font } => { + span(text.clone()).color_maybe(*color).font_maybe(*font) + } + } + } +} + +/// Parse the given Markdown content. +pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ { + struct List { + start: Option<u64>, + items: Vec<Vec<Item>>, + } + + let mut spans = Vec::new(); + let mut strong = false; + let mut emphasis = false; + let mut strikethrough = false; + let mut metadata = false; + let mut table = false; + let mut link = None; + let mut lists = Vec::new(); + + #[cfg(feature = "highlighter")] + let mut highlighter = None; + + let parser = pulldown_cmark::Parser::new_ext( + markdown, + pulldown_cmark::Options::ENABLE_YAML_STYLE_METADATA_BLOCKS + | pulldown_cmark::Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS + | pulldown_cmark::Options::ENABLE_TABLES + | pulldown_cmark::Options::ENABLE_STRIKETHROUGH, + ); + + let produce = |lists: &mut Vec<List>, item| { + if lists.is_empty() { + Some(item) + } else { + lists + .last_mut() + .expect("list context") + .items + .last_mut() + .expect("item context") + .push(item); + + None + } + }; + + // We want to keep the `spans` capacity + #[allow(clippy::drain_collect)] + parser.filter_map(move |event| match event { + pulldown_cmark::Event::Start(tag) => match tag { + pulldown_cmark::Tag::Strong if !metadata && !table => { + strong = true; + None + } + pulldown_cmark::Tag::Emphasis if !metadata && !table => { + emphasis = true; + None + } + pulldown_cmark::Tag::Strikethrough if !metadata && !table => { + strikethrough = true; + None + } + pulldown_cmark::Tag::Link { dest_url, .. } + if !metadata && !table => + { + match Url::parse(&dest_url) { + Ok(url) + if url.scheme() == "http" + || url.scheme() == "https" => + { + link = Some(url); + } + _ => {} + } + + None + } + pulldown_cmark::Tag::List(first_item) if !metadata && !table => { + lists.push(List { + start: first_item, + items: Vec::new(), + }); + + None + } + pulldown_cmark::Tag::Item => { + lists + .last_mut() + .expect("list context") + .items + .push(Vec::new()); + None + } + pulldown_cmark::Tag::CodeBlock( + pulldown_cmark::CodeBlockKind::Fenced(_language), + ) if !metadata && !table => { + #[cfg(feature = "highlighter")] + { + use iced_highlighter::{self, Highlighter}; + use text::Highlighter as _; + + highlighter = + Some(Highlighter::new(&iced_highlighter::Settings { + theme: iced_highlighter::Theme::Base16Ocean, + token: _language.to_string(), + })); + } + + None + } + pulldown_cmark::Tag::MetadataBlock(_) => { + metadata = true; + None + } + pulldown_cmark::Tag::Table(_) => { + table = true; + None + } + _ => None, + }, + pulldown_cmark::Event::End(tag) => match tag { + pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => { + produce( + &mut lists, + Item::Heading(level, Text::new(spans.drain(..).collect())), + ) + } + pulldown_cmark::TagEnd::Strong if !metadata && !table => { + strong = false; + None + } + pulldown_cmark::TagEnd::Emphasis if !metadata && !table => { + emphasis = false; + None + } + pulldown_cmark::TagEnd::Strikethrough if !metadata && !table => { + strikethrough = false; + None + } + pulldown_cmark::TagEnd::Link if !metadata && !table => { + link = None; + None + } + pulldown_cmark::TagEnd::Paragraph if !metadata && !table => { + produce( + &mut lists, + Item::Paragraph(Text::new(spans.drain(..).collect())), + ) + } + pulldown_cmark::TagEnd::Item if !metadata && !table => { + if spans.is_empty() { + None + } else { + produce( + &mut lists, + Item::Paragraph(Text::new(spans.drain(..).collect())), + ) + } + } + pulldown_cmark::TagEnd::List(_) if !metadata && !table => { + let list = lists.pop().expect("list context"); + + produce( + &mut lists, + Item::List { + start: list.start, + items: list.items, + }, + ) + } + pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => { + #[cfg(feature = "highlighter")] + { + highlighter = None; + } + + produce( + &mut lists, + Item::CodeBlock(Text::new(spans.drain(..).collect())), + ) + } + pulldown_cmark::TagEnd::MetadataBlock(_) => { + metadata = false; + None + } + pulldown_cmark::TagEnd::Table => { + table = false; + None + } + _ => None, + }, + pulldown_cmark::Event::Text(text) if !metadata && !table => { + #[cfg(feature = "highlighter")] + if let Some(highlighter) = &mut highlighter { + use text::Highlighter as _; + + for (range, highlight) in + highlighter.highlight_line(text.as_ref()) + { + let span = Span::Highlight { + text: text[range].to_owned(), + color: highlight.color(), + font: highlight.font(), + }; + + spans.push(span); + } + + return None; + } + + let span = Span::Standard { + text: text.into_string(), + strong, + emphasis, + strikethrough, + link: link.clone(), + code: false, + }; + + spans.push(span); + + None + } + pulldown_cmark::Event::Code(code) if !metadata && !table => { + let span = Span::Standard { + text: code.into_string(), + strong, + emphasis, + strikethrough, + link: link.clone(), + code: true, + }; + + spans.push(span); + None + } + pulldown_cmark::Event::SoftBreak if !metadata && !table => { + spans.push(Span::Standard { + text: String::from(" "), + strikethrough, + strong, + emphasis, + link: link.clone(), + code: false, + }); + None + } + pulldown_cmark::Event::HardBreak if !metadata && !table => { + spans.push(Span::Standard { + text: String::from("\n"), + strikethrough, + strong, + emphasis, + link: link.clone(), + code: false, + }); + None + } + _ => None, + }) +} + +/// Configuration controlling Markdown rendering in [`view`]. +#[derive(Debug, Clone, Copy)] +pub struct Settings { + /// The base text size. + pub text_size: Pixels, + /// The text size of level 1 heading. + pub h1_size: Pixels, + /// The text size of level 2 heading. + pub h2_size: Pixels, + /// The text size of level 3 heading. + pub h3_size: Pixels, + /// The text size of level 4 heading. + pub h4_size: Pixels, + /// The text size of level 5 heading. + pub h5_size: Pixels, + /// The text size of level 6 heading. + pub h6_size: Pixels, + /// The text size used in code blocks. + pub code_size: Pixels, +} + +impl Settings { + /// Creates new [`Settings`] with the given base text size in [`Pixels`]. + /// + /// Heading levels will be adjusted automatically. Specifically, + /// the first level will be twice the base size, and then every level + /// after that will be 25% smaller. + pub fn with_text_size(text_size: impl Into<Pixels>) -> Self { + let text_size = text_size.into(); + + Self { + text_size, + h1_size: text_size * 2.0, + h2_size: text_size * 1.75, + h3_size: text_size * 1.5, + h4_size: text_size * 1.25, + h5_size: text_size, + h6_size: text_size, + code_size: text_size * 0.75, + } + } +} + +impl Default for Settings { + fn default() -> Self { + Self::with_text_size(16) + } +} + +/// The text styling of some Markdown rendering in [`view`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Style { + /// The [`Highlight`] to be applied to the background of inline code. + pub inline_code_highlight: Highlight, + /// The [`Padding`] to be applied to the background of inline code. + pub inline_code_padding: Padding, + /// The [`Color`] to be applied to inline code. + pub inline_code_color: Color, + /// The [`Color`] to be applied to links. + pub link_color: Color, +} + +impl Style { + /// Creates a new [`Style`] from the given [`theme::Palette`]. + pub fn from_palette(palette: theme::Palette) -> Self { + Self { + inline_code_padding: padding::left(1).right(1), + inline_code_highlight: Highlight { + background: color!(0x111).into(), + border: border::rounded(2), + }, + inline_code_color: Color::WHITE, + link_color: palette.primary, + } + } +} + +/// Display a bunch of Markdown items. +/// +/// You can obtain the items with [`parse`]. +pub fn view<'a, Theme, Renderer>( + items: impl IntoIterator<Item = &'a Item>, + settings: Settings, + style: Style, +) -> Element<'a, Url, Theme, Renderer> +where + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Font = Font> + 'a, +{ + let Settings { + text_size, + h1_size, + h2_size, + h3_size, + h4_size, + h5_size, + h6_size, + code_size, + } = settings; + + let spacing = text_size * 0.625; + + let blocks = items.into_iter().enumerate().map(|(i, item)| match item { + Item::Heading(level, heading) => { + container(rich_text(heading.spans(style)).size(match level { + pulldown_cmark::HeadingLevel::H1 => h1_size, + pulldown_cmark::HeadingLevel::H2 => h2_size, + pulldown_cmark::HeadingLevel::H3 => h3_size, + pulldown_cmark::HeadingLevel::H4 => h4_size, + pulldown_cmark::HeadingLevel::H5 => h5_size, + pulldown_cmark::HeadingLevel::H6 => h6_size, + })) + .padding(padding::top(if i > 0 { + text_size / 2.0 + } else { + Pixels::ZERO + })) + .into() + } + Item::Paragraph(paragraph) => { + rich_text(paragraph.spans(style)).size(text_size).into() + } + Item::List { start: None, items } => { + column(items.iter().map(|items| { + row![text("•").size(text_size), view(items, settings, style)] + .spacing(spacing) + .into() + })) + .spacing(spacing) + .into() + } + Item::List { + start: Some(start), + items, + } => column(items.iter().enumerate().map(|(i, items)| { + row![ + text!("{}.", i as u64 + *start).size(text_size), + view(items, settings, style) + ] + .spacing(spacing) + .into() + })) + .spacing(spacing) + .into(), + Item::CodeBlock(code) => container( + scrollable( + container( + rich_text(code.spans(style)) + .font(Font::MONOSPACE) + .size(code_size), + ) + .padding(spacing.0 / 2.0), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default() + .width(spacing.0 / 2.0) + .scroller_width(spacing.0 / 2.0), + )), + ) + .width(Length::Fill) + .padding(spacing.0 / 2.0) + .class(Theme::code_block()) + .into(), + }); + + Element::new(column(blocks).width(Length::Fill).spacing(text_size)) +} + +/// The theme catalog of Markdown items. +pub trait Catalog: + container::Catalog + scrollable::Catalog + text::Catalog +{ + /// The styling class of a Markdown code block. + fn code_block<'a>() -> <Self as container::Catalog>::Class<'a>; +} + +impl Catalog for Theme { + fn code_block<'a>() -> <Self as container::Catalog>::Class<'a> { + Box::new(container::dark) + } +} diff --git a/widget/src/mouse_area.rs b/widget/src/mouse_area.rs index 17cae53b..d255ac99 100644 --- a/widget/src/mouse_area.rs +++ b/widget/src/mouse_area.rs @@ -1,7 +1,4 @@ //! A container for capturing mouse events. - -use iced_renderer::core::Point; - use crate::core::event::{self, Event}; use crate::core::layout; use crate::core::mouse; @@ -10,7 +7,8 @@ use crate::core::renderer; use crate::core::touch; use crate::core::widget::{tree, Operation, Tree}; use crate::core::{ - Clipboard, Element, Layout, Length, Rectangle, Shell, Size, Vector, Widget, + Clipboard, Element, Layout, Length, Point, Rectangle, Shell, Size, Vector, + Widget, }; /// Emit messages on mouse events. @@ -28,8 +26,9 @@ pub struct MouseArea< on_right_release: Option<Message>, on_middle_press: Option<Message>, on_middle_release: Option<Message>, + on_scroll: Option<Box<dyn Fn(mouse::ScrollDelta) -> Message + 'a>>, on_enter: Option<Message>, - on_move: Option<Box<dyn Fn(Point) -> Message>>, + on_move: Option<Box<dyn Fn(Point) -> Message + 'a>>, on_exit: Option<Message>, interaction: Option<mouse::Interaction>, } @@ -77,6 +76,16 @@ impl<'a, Message, Theme, Renderer> MouseArea<'a, Message, Theme, Renderer> { self } + /// The message to emit when scroll wheel is used + #[must_use] + pub fn on_scroll( + mut self, + on_scroll: impl Fn(mouse::ScrollDelta) -> Message + 'a, + ) -> Self { + self.on_scroll = Some(Box::new(on_scroll)); + self + } + /// The message to emit when the mouse enters the area. #[must_use] pub fn on_enter(mut self, message: Message) -> Self { @@ -86,11 +95,8 @@ impl<'a, Message, Theme, Renderer> MouseArea<'a, Message, Theme, Renderer> { /// The message to emit when the mouse moves in the area. #[must_use] - pub fn on_move<F>(mut self, build_message: F) -> Self - where - F: Fn(Point) -> Message + 'static, - { - self.on_move = Some(Box::new(build_message)); + pub fn on_move(mut self, on_move: impl Fn(Point) -> Message + 'a) -> Self { + self.on_move = Some(Box::new(on_move)); self } @@ -113,6 +119,8 @@ impl<'a, Message, Theme, Renderer> MouseArea<'a, Message, Theme, Renderer> { #[derive(Default)] struct State { is_hovered: bool, + bounds: Rectangle, + cursor_position: Option<Point>, } impl<'a, Message, Theme, Renderer> MouseArea<'a, Message, Theme, Renderer> { @@ -128,6 +136,7 @@ impl<'a, Message, Theme, Renderer> MouseArea<'a, Message, Theme, Renderer> { on_right_release: None, on_middle_press: None, on_middle_release: None, + on_scroll: None, on_enter: None, on_move: None, on_exit: None, @@ -178,7 +187,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { self.content.as_widget().operate( &mut tree.children[0], @@ -302,13 +311,17 @@ fn update<Message: Clone, Theme, Renderer>( cursor: mouse::Cursor, shell: &mut Shell<'_, Message>, ) -> event::Status { - if let Event::Mouse(mouse::Event::CursorMoved { .. }) - | Event::Touch(touch::Event::FingerMoved { .. }) = event - { - let state: &mut State = tree.state.downcast_mut(); + let state: &mut State = tree.state.downcast_mut(); + let cursor_position = cursor.position(); + let bounds = layout.bounds(); + + if state.cursor_position != cursor_position && state.bounds != bounds { let was_hovered = state.is_hovered; + state.is_hovered = cursor.is_over(layout.bounds()); + state.cursor_position = cursor_position; + state.bounds = bounds; match ( widget.on_enter.as_ref(), @@ -397,5 +410,13 @@ fn update<Message: Clone, Theme, Renderer>( } } + if let Some(on_scroll) = widget.on_scroll.as_ref() { + if let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event { + shell.publish(on_scroll(delta)); + + return event::Status::Captured; + } + } + event::Status::Ignored } diff --git a/widget/src/overlay/menu.rs b/widget/src/overlay/menu.rs index 98efe305..f05ae40a 100644 --- a/widget/src/overlay/menu.rs +++ b/widget/src/overlay/menu.rs @@ -1,5 +1,6 @@ //! Build and show dropdown menus. use crate::core::alignment; +use crate::core::border::{self, Border}; use crate::core::event::{self, Event}; use crate::core::layout::{self, Layout}; use crate::core::mouse; @@ -9,8 +10,8 @@ use crate::core::text::{self, Text}; use crate::core::touch; use crate::core::widget::Tree; use crate::core::{ - Background, Border, Clipboard, Color, Length, Padding, Pixels, Point, - Rectangle, Size, Theme, Vector, + Background, Clipboard, Color, Length, Padding, Pixels, Point, Rectangle, + Size, Theme, Vector, }; use crate::core::{Element, Shell, Widget}; use crate::scrollable::{self, Scrollable}; @@ -200,21 +201,18 @@ where class, } = menu; - let list = Scrollable::with_direction( - List { - options, - hovered_option, - on_selected, - on_option_hovered, - font, - text_size, - text_line_height, - text_shaping, - padding, - class, - }, - scrollable::Direction::default(), - ); + let list = Scrollable::new(List { + options, + hovered_option, + on_selected, + on_option_hovered, + font, + text_size, + text_line_height, + text_shaping, + padding, + class, + }); state.tree.diff(&list as &dyn Widget<_, _, _>); @@ -517,7 +515,7 @@ where width: bounds.width - style.border.width * 2.0, ..bounds }, - border: Border::rounded(style.border.radius), + border: border::rounded(style.border.radius), ..renderer::Quad::default() }, style.selected_background, @@ -534,6 +532,7 @@ where horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, shaping: self.text_shaping, + wrapping: text::Wrapping::default(), }, Point::new(bounds.x + self.padding.left, bounds.center_y()), if is_selected { diff --git a/widget/src/pane_grid.rs b/widget/src/pane_grid.rs index c3da3879..710a5443 100644 --- a/widget/src/pane_grid.rs +++ b/widget/src/pane_grid.rs @@ -10,6 +10,7 @@ mod axis; mod configuration; mod content; +mod controls; mod direction; mod draggable; mod node; @@ -22,6 +23,7 @@ pub mod state; pub use axis::Axis; pub use configuration::Configuration; pub use content::Content; +pub use controls::Controls; pub use direction::Direction; pub use draggable::Draggable; pub use node::Node; @@ -324,7 +326,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn widget::Operation<()>, + operation: &mut dyn widget::Operation, ) { operation.container(None, layout.bounds(), &mut |operation| { self.contents diff --git a/widget/src/pane_grid/content.rs b/widget/src/pane_grid/content.rs index d45fc0cd..ec0676b1 100644 --- a/widget/src/pane_grid/content.rs +++ b/widget/src/pane_grid/content.rs @@ -214,7 +214,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn widget::Operation<()>, + operation: &mut dyn widget::Operation, ) { let body_layout = if let Some(title_bar) = &self.title_bar { let mut children = layout.children(); diff --git a/widget/src/pane_grid/controls.rs b/widget/src/pane_grid/controls.rs new file mode 100644 index 00000000..13b57acb --- /dev/null +++ b/widget/src/pane_grid/controls.rs @@ -0,0 +1,59 @@ +use crate::container; +use crate::core::{self, Element}; + +/// The controls of a [`Pane`]. +/// +/// [`Pane`]: super::Pane +#[allow(missing_debug_implementations)] +pub struct Controls< + 'a, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> where + Theme: container::Catalog, + Renderer: core::Renderer, +{ + pub(super) full: Element<'a, Message, Theme, Renderer>, + pub(super) compact: Option<Element<'a, Message, Theme, Renderer>>, +} + +impl<'a, Message, Theme, Renderer> Controls<'a, Message, Theme, Renderer> +where + Theme: container::Catalog, + Renderer: core::Renderer, +{ + /// Creates a new [`Controls`] with the given content. + pub fn new( + content: impl Into<Element<'a, Message, Theme, Renderer>>, + ) -> Self { + Self { + full: content.into(), + compact: None, + } + } + + /// Creates a new [`Controls`] with a full and compact variant. + /// If there is not enough room to show the full variant without overlap, + /// then the compact variant will be shown instead. + pub fn dynamic( + full: impl Into<Element<'a, Message, Theme, Renderer>>, + compact: impl Into<Element<'a, Message, Theme, Renderer>>, + ) -> Self { + Self { + full: full.into(), + compact: Some(compact.into()), + } + } +} + +impl<'a, Message, Theme, Renderer> From<Element<'a, Message, Theme, Renderer>> + for Controls<'a, Message, Theme, Renderer> +where + Theme: container::Catalog, + Renderer: core::Renderer, +{ + fn from(value: Element<'a, Message, Theme, Renderer>) -> Self { + Self::new(value) + } +} diff --git a/widget/src/pane_grid/title_bar.rs b/widget/src/pane_grid/title_bar.rs index c05f1252..5002b4f7 100644 --- a/widget/src/pane_grid/title_bar.rs +++ b/widget/src/pane_grid/title_bar.rs @@ -9,6 +9,7 @@ use crate::core::{ self, Clipboard, Element, Layout, Padding, Point, Rectangle, Shell, Size, Vector, }; +use crate::pane_grid::controls::Controls; /// The title bar of a [`Pane`]. /// @@ -24,7 +25,7 @@ pub struct TitleBar< Renderer: core::Renderer, { content: Element<'a, Message, Theme, Renderer>, - controls: Option<Element<'a, Message, Theme, Renderer>>, + controls: Option<Controls<'a, Message, Theme, Renderer>>, padding: Padding, always_show_controls: bool, class: Theme::Class<'a>, @@ -51,7 +52,7 @@ where /// Sets the controls of the [`TitleBar`]. pub fn controls( mut self, - controls: impl Into<Element<'a, Message, Theme, Renderer>>, + controls: impl Into<Controls<'a, Message, Theme, Renderer>>, ) -> Self { self.controls = Some(controls.into()); self @@ -104,10 +105,22 @@ where Renderer: core::Renderer, { pub(super) fn state(&self) -> Tree { - let children = if let Some(controls) = self.controls.as_ref() { - vec![Tree::new(&self.content), Tree::new(controls)] - } else { - vec![Tree::new(&self.content), Tree::empty()] + let children = match self.controls.as_ref() { + Some(controls) => match controls.compact.as_ref() { + Some(compact) => vec![ + Tree::new(&self.content), + Tree::new(&controls.full), + Tree::new(compact), + ], + None => vec![ + Tree::new(&self.content), + Tree::new(&controls.full), + Tree::empty(), + ], + }, + None => { + vec![Tree::new(&self.content), Tree::empty(), Tree::empty()] + } }; Tree { @@ -117,9 +130,13 @@ where } pub(super) fn diff(&self, tree: &mut Tree) { - if tree.children.len() == 2 { + if tree.children.len() == 3 { if let Some(controls) = self.controls.as_ref() { - tree.children[1].diff(controls); + if let Some(compact) = controls.compact.as_ref() { + tree.children[2].diff(compact); + } + + tree.children[1].diff(&controls.full); } tree.children[0].diff(&self.content); @@ -164,18 +181,42 @@ where if title_layout.bounds().width + controls_layout.bounds().width > padded.bounds().width { - show_title = false; + if let Some(compact) = controls.compact.as_ref() { + let compact_layout = children.next().unwrap(); + + compact.as_widget().draw( + &tree.children[2], + renderer, + theme, + &inherited_style, + compact_layout, + cursor, + viewport, + ); + } else { + show_title = false; + + controls.full.as_widget().draw( + &tree.children[1], + renderer, + theme, + &inherited_style, + controls_layout, + cursor, + viewport, + ); + } + } else { + controls.full.as_widget().draw( + &tree.children[1], + renderer, + theme, + &inherited_style, + controls_layout, + cursor, + viewport, + ); } - - controls.as_widget().draw( - &tree.children[1], - renderer, - theme, - &inherited_style, - controls_layout, - cursor, - viewport, - ); } } @@ -207,13 +248,20 @@ where let mut children = padded.children(); let title_layout = children.next().unwrap(); - if self.controls.is_some() { + if let Some(controls) = self.controls.as_ref() { let controls_layout = children.next().unwrap(); if title_layout.bounds().width + controls_layout.bounds().width > padded.bounds().width { - !controls_layout.bounds().contains(cursor_position) + if controls.compact.is_some() { + let compact_layout = children.next().unwrap(); + + !compact_layout.bounds().contains(cursor_position) + && !title_layout.bounds().contains(cursor_position) + } else { + !controls_layout.bounds().contains(cursor_position) + } } else { !controls_layout.bounds().contains(cursor_position) && !title_layout.bounds().contains(cursor_position) @@ -244,25 +292,73 @@ where let title_size = title_layout.size(); let node = if let Some(controls) = &self.controls { - let controls_layout = controls.as_widget().layout( + let controls_layout = controls.full.as_widget().layout( &mut tree.children[1], renderer, &layout::Limits::new(Size::ZERO, max_size), ); - let controls_size = controls_layout.size(); - let space_before_controls = max_size.width - controls_size.width; - - let height = title_size.height.max(controls_size.height); - - layout::Node::with_children( - Size::new(max_size.width, height), - vec![ - title_layout, - controls_layout - .move_to(Point::new(space_before_controls, 0.0)), - ], - ) + if title_layout.bounds().width + controls_layout.bounds().width + > max_size.width + { + if let Some(compact) = controls.compact.as_ref() { + let compact_layout = compact.as_widget().layout( + &mut tree.children[2], + renderer, + &layout::Limits::new(Size::ZERO, max_size), + ); + + let compact_size = compact_layout.size(); + let space_before_controls = + max_size.width - compact_size.width; + + let height = title_size.height.max(compact_size.height); + + layout::Node::with_children( + Size::new(max_size.width, height), + vec![ + title_layout, + controls_layout, + compact_layout.move_to(Point::new( + space_before_controls, + 0.0, + )), + ], + ) + } else { + let controls_size = controls_layout.size(); + let space_before_controls = + max_size.width - controls_size.width; + + let height = title_size.height.max(controls_size.height); + + layout::Node::with_children( + Size::new(max_size.width, height), + vec![ + title_layout, + controls_layout.move_to(Point::new( + space_before_controls, + 0.0, + )), + ], + ) + } + } else { + let controls_size = controls_layout.size(); + let space_before_controls = + max_size.width - controls_size.width; + + let height = title_size.height.max(controls_size.height); + + layout::Node::with_children( + Size::new(max_size.width, height), + vec![ + title_layout, + controls_layout + .move_to(Point::new(space_before_controls, 0.0)), + ], + ) + } } else { layout::Node::with_children( Size::new(max_size.width, title_size.height), @@ -278,7 +374,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn widget::Operation<()>, + operation: &mut dyn widget::Operation, ) { let mut children = layout.children(); let padded = children.next().unwrap(); @@ -293,15 +389,33 @@ where if title_layout.bounds().width + controls_layout.bounds().width > padded.bounds().width { - show_title = false; - } + if let Some(compact) = controls.compact.as_ref() { + let compact_layout = children.next().unwrap(); - controls.as_widget().operate( - &mut tree.children[1], - controls_layout, - renderer, - operation, - ); + compact.as_widget().operate( + &mut tree.children[2], + compact_layout, + renderer, + operation, + ); + } else { + show_title = false; + + controls.full.as_widget().operate( + &mut tree.children[1], + controls_layout, + renderer, + operation, + ); + } + } else { + controls.full.as_widget().operate( + &mut tree.children[1], + controls_layout, + renderer, + operation, + ); + } }; if show_title { @@ -337,19 +451,45 @@ where if title_layout.bounds().width + controls_layout.bounds().width > padded.bounds().width { - show_title = false; - } + if let Some(compact) = controls.compact.as_mut() { + let compact_layout = children.next().unwrap(); + + compact.as_widget_mut().on_event( + &mut tree.children[2], + event.clone(), + compact_layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } else { + show_title = false; - controls.as_widget_mut().on_event( - &mut tree.children[1], - event.clone(), - controls_layout, - cursor, - renderer, - clipboard, - shell, - viewport, - ) + controls.full.as_widget_mut().on_event( + &mut tree.children[1], + event.clone(), + controls_layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } + } else { + controls.full.as_widget_mut().on_event( + &mut tree.children[1], + event.clone(), + controls_layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } } else { event::Status::Ignored }; @@ -396,18 +536,33 @@ where if let Some(controls) = &self.controls { let controls_layout = children.next().unwrap(); - let controls_interaction = controls.as_widget().mouse_interaction( - &tree.children[1], - controls_layout, - cursor, - viewport, - renderer, - ); + let controls_interaction = + controls.full.as_widget().mouse_interaction( + &tree.children[1], + controls_layout, + cursor, + viewport, + renderer, + ); if title_layout.bounds().width + controls_layout.bounds().width > padded.bounds().width { - controls_interaction + if let Some(compact) = controls.compact.as_ref() { + let compact_layout = children.next().unwrap(); + let compact_interaction = + compact.as_widget().mouse_interaction( + &tree.children[2], + compact_layout, + cursor, + viewport, + renderer, + ); + + compact_interaction.max(title_interaction) + } else { + controls_interaction + } } else { controls_interaction.max(title_interaction) } @@ -444,12 +599,36 @@ where controls.as_mut().and_then(|controls| { let controls_layout = children.next()?; - controls.as_widget_mut().overlay( - controls_state, - controls_layout, - renderer, - translation, - ) + if title_layout.bounds().width + + controls_layout.bounds().width + > padded.bounds().width + { + if let Some(compact) = controls.compact.as_mut() { + let compact_state = states.next().unwrap(); + let compact_layout = children.next()?; + + compact.as_widget_mut().overlay( + compact_state, + compact_layout, + renderer, + translation, + ) + } else { + controls.full.as_widget_mut().overlay( + controls_state, + controls_layout, + renderer, + translation, + ) + } + } else { + controls.full.as_widget_mut().overlay( + controls_state, + controls_layout, + renderer, + translation, + ) + } }) }) } diff --git a/widget/src/pick_list.rs b/widget/src/pick_list.rs index 97de5b48..1fc9951e 100644 --- a/widget/src/pick_list.rs +++ b/widget/src/pick_list.rs @@ -6,7 +6,8 @@ use crate::core::layout; use crate::core::mouse; use crate::core::overlay; use crate::core::renderer; -use crate::core::text::{self, Paragraph as _, Text}; +use crate::core::text::paragraph; +use crate::core::text::{self, Text}; use crate::core::touch; use crate::core::widget::tree::{self, Tree}; use crate::core::{ @@ -80,7 +81,7 @@ where padding: crate::button::DEFAULT_PADDING, text_size: None, text_line_height: text::LineHeight::default(), - text_shaping: text::Shaping::Basic, + text_shaping: text::Shaping::default(), font: None, handle: Handle::default(), class: <Theme as Catalog>::default(), @@ -249,6 +250,7 @@ where horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, shaping: self.text_shaping, + wrapping: text::Wrapping::default(), }; for (option, paragraph) in options.iter().zip(state.options.iter_mut()) @@ -514,6 +516,7 @@ where horizontal_alignment: alignment::Horizontal::Right, vertical_alignment: alignment::Vertical::Center, shaping, + wrapping: text::Wrapping::default(), }, Point::new( bounds.x + bounds.width - self.padding.right, @@ -543,6 +546,7 @@ where horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, shaping: self.text_shaping, + wrapping: text::Wrapping::default(), }, Point::new(bounds.x + self.padding.left, bounds.center_y()), if is_selected { @@ -622,8 +626,8 @@ struct State<P: text::Paragraph> { keyboard_modifiers: keyboard::Modifiers, is_open: bool, hovered_option: Option<usize>, - options: Vec<P>, - placeholder: P, + options: Vec<paragraph::Plain<P>>, + placeholder: paragraph::Plain<P>, } impl<P: text::Paragraph> State<P> { @@ -635,7 +639,7 @@ impl<P: text::Paragraph> State<P> { is_open: bool::default(), hovered_option: Option::default(), options: Vec::new(), - placeholder: P::default(), + placeholder: paragraph::Plain::default(), } } } diff --git a/widget/src/progress_bar.rs b/widget/src/progress_bar.rs index e7821b43..a10feea6 100644 --- a/widget/src/progress_bar.rs +++ b/widget/src/progress_bar.rs @@ -1,10 +1,11 @@ //! Provide progress feedback to your users. +use crate::core::border::{self, Border}; use crate::core::layout; use crate::core::mouse; use crate::core::renderer; use crate::core::widget::Tree; use crate::core::{ - self, Background, Border, Element, Layout, Length, Rectangle, Size, Theme, + self, Background, Color, Element, Layout, Length, Rectangle, Size, Theme, Widget, }; @@ -151,7 +152,10 @@ where width: active_progress_width, ..bounds }, - border: Border::rounded(style.border.radius), + border: Border { + color: Color::TRANSPARENT, + ..style.border + }, ..renderer::Quad::default() }, style.bar, @@ -255,6 +259,6 @@ fn styled( Style { background: background.into(), bar: bar.into(), - border: Border::rounded(2), + border: border::rounded(2), } } diff --git a/widget/src/radio.rs b/widget/src/radio.rs index 6b22961d..cfa961f3 100644 --- a/widget/src/radio.rs +++ b/widget/src/radio.rs @@ -1,5 +1,6 @@ //! Create choices using radio buttons. use crate::core::alignment; +use crate::core::border::{self, Border}; use crate::core::event::{self, Event}; use crate::core::layout; use crate::core::mouse; @@ -9,8 +10,8 @@ use crate::core::touch; use crate::core::widget; use crate::core::widget::tree::{self, Tree}; use crate::core::{ - Background, Border, Clipboard, Color, Element, Layout, Length, Pixels, - Rectangle, Shell, Size, Theme, Widget, + Background, Clipboard, Color, Element, Layout, Length, Pixels, Rectangle, + Shell, Size, Theme, Widget, }; /// A circular button representing a choice. @@ -81,6 +82,7 @@ where text_size: Option<Pixels>, text_line_height: text::LineHeight, text_shaping: text::Shaping, + text_wrapping: text::Wrapping, font: Option<Renderer::Font>, class: Theme::Class<'a>, } @@ -104,7 +106,7 @@ where /// * the label of the [`Radio`] button /// * the current selected value /// * a function that will be called when the [`Radio`] is selected. It - /// receives the value of the radio and must produce a `Message`. + /// receives the value of the radio and must produce a `Message`. pub fn new<F, V>( label: impl Into<String>, value: V, @@ -121,10 +123,11 @@ where label: label.into(), width: Length::Shrink, size: Self::DEFAULT_SIZE, - spacing: Self::DEFAULT_SPACING, //15 + spacing: Self::DEFAULT_SPACING, text_size: None, text_line_height: text::LineHeight::default(), - text_shaping: text::Shaping::Basic, + text_shaping: text::Shaping::default(), + text_wrapping: text::Wrapping::default(), font: None, class: Theme::default(), } @@ -169,6 +172,12 @@ where self } + /// Sets the [`text::Wrapping`] strategy of the [`Radio`] button. + pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self { + self.text_wrapping = wrapping; + self + } + /// Sets the text font of the [`Radio`] button. pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self { self.font = Some(font.into()); @@ -244,6 +253,7 @@ where alignment::Horizontal::Left, alignment::Vertical::Top, self.text_shaping, + self.text_wrapping, ) }, ) @@ -342,7 +352,7 @@ where width: bounds.width - dot_size, height: bounds.height - dot_size, }, - border: Border::rounded(dot_size / 2.0), + border: border::rounded(dot_size / 2.0), ..renderer::Quad::default() }, style.dot_color, @@ -352,12 +362,14 @@ where { let label_layout = children.next().unwrap(); + let state: &widget::text::State<Renderer::Paragraph> = + tree.state.downcast_ref(); crate::text::draw( renderer, defaults, label_layout, - tree.state.downcast_ref(), + state.0.raw(), crate::text::Style { color: style.text_color, }, diff --git a/widget/src/row.rs b/widget/src/row.rs index c8fcdb61..85af912f 100644 --- a/widget/src/row.rs +++ b/widget/src/row.rs @@ -1,4 +1,5 @@ //! Distribute content horizontally. +use crate::core::alignment::{self, Alignment}; use crate::core::event::{self, Event}; use crate::core::layout::{self, Layout}; use crate::core::mouse; @@ -6,8 +7,8 @@ use crate::core::overlay; use crate::core::renderer; use crate::core::widget::{Operation, Tree}; use crate::core::{ - Alignment, Clipboard, Element, Length, Padding, Pixels, Rectangle, Shell, - Size, Vector, Widget, + Clipboard, Element, Length, Padding, Pixels, Rectangle, Shell, Size, + Vector, Widget, }; /// A container that distributes its contents horizontally. @@ -17,7 +18,7 @@ pub struct Row<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> { padding: Padding, width: Length, height: Length, - align_items: Alignment, + align: Alignment, clip: bool, children: Vec<Element<'a, Message, Theme, Renderer>>, } @@ -60,7 +61,7 @@ where padding: Padding::ZERO, width: Length::Shrink, height: Length::Shrink, - align_items: Alignment::Start, + align: Alignment::Start, clip: false, children, } @@ -95,8 +96,8 @@ where } /// Sets the vertical alignment of the contents of the [`Row`] . - pub fn align_items(mut self, align: Alignment) -> Self { - self.align_items = align; + pub fn align_y(mut self, align: impl Into<alignment::Vertical>) -> Self { + self.align = Alignment::from(align.into()); self } @@ -141,6 +142,13 @@ where ) -> Self { children.into_iter().fold(self, Self::push) } + + /// Turns the [`Row`] into a [`Wrapping`] row. + /// + /// The original alignment of the [`Row`] is preserved per row wrapped. + pub fn wrap(self) -> Wrapping<'a, Message, Theme, Renderer> { + Wrapping { row: self } + } } impl<'a, Message, Renderer> Default for Row<'a, Message, Renderer> @@ -199,7 +207,7 @@ where self.height, self.padding, self.spacing, - self.align_items, + self.align, &self.children, &mut tree.children, ) @@ -210,7 +218,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { operation.container(None, layout.bounds(), &mut |operation| { self.children @@ -338,3 +346,196 @@ where Self::new(row) } } + +/// A [`Row`] that wraps its contents. +/// +/// Create a [`Row`] first, and then call [`Row::wrap`] to +/// obtain a [`Row`] that wraps its contents. +/// +/// The original alignment of the [`Row`] is preserved per row wrapped. +#[allow(missing_debug_implementations)] +pub struct Wrapping< + 'a, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> { + row: Row<'a, Message, Theme, Renderer>, +} + +impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer> + for Wrapping<'a, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn children(&self) -> Vec<Tree> { + self.row.children() + } + + fn diff(&self, tree: &mut Tree) { + self.row.diff(tree); + } + + fn size(&self) -> Size<Length> { + self.row.size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let limits = limits + .width(self.row.width) + .height(self.row.height) + .shrink(self.row.padding); + + let spacing = self.row.spacing; + let max_width = limits.max().width; + + let mut children: Vec<layout::Node> = Vec::new(); + let mut intrinsic_size = Size::ZERO; + let mut row_start = 0; + let mut row_height = 0.0; + let mut x = 0.0; + let mut y = 0.0; + + let align_factor = match self.row.align { + Alignment::Start => 0.0, + Alignment::Center => 2.0, + Alignment::End => 1.0, + }; + + let align = |row_start: std::ops::Range<usize>, + row_height: f32, + children: &mut Vec<layout::Node>| { + if align_factor != 0.0 { + for node in &mut children[row_start] { + let height = node.size().height; + + node.translate_mut(Vector::new( + 0.0, + (row_height - height) / align_factor, + )); + } + } + }; + + for (i, child) in self.row.children.iter().enumerate() { + let node = child.as_widget().layout( + &mut tree.children[i], + renderer, + &limits, + ); + + let child_size = node.size(); + + if x != 0.0 && x + child_size.width > max_width { + intrinsic_size.width = intrinsic_size.width.max(x - spacing); + + align(row_start..i, row_height, &mut children); + + y += row_height + spacing; + x = 0.0; + row_start = i; + row_height = 0.0; + } + + row_height = row_height.max(child_size.height); + + children.push(node.move_to(( + x + self.row.padding.left, + y + self.row.padding.top, + ))); + + x += child_size.width + spacing; + } + + if x != 0.0 { + intrinsic_size.width = intrinsic_size.width.max(x - spacing); + } + + intrinsic_size.height = (y - spacing).max(0.0) + row_height; + align(row_start..children.len(), row_height, &mut children); + + let size = + limits.resolve(self.row.width, self.row.height, intrinsic_size); + + layout::Node::with_children(size.expand(self.row.padding), children) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + self.row.operate(tree, layout, renderer, operation); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + self.row.on_event( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.row + .mouse_interaction(tree, layout, cursor, viewport, renderer) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.row + .draw(tree, renderer, theme, style, layout, cursor, viewport); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + translation: Vector, + ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> { + self.row.overlay(tree, layout, renderer, translation) + } +} + +impl<'a, Message, Theme, Renderer> From<Wrapping<'a, Message, Theme, Renderer>> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: crate::core::Renderer + 'a, +{ + fn from(row: Wrapping<'a, Message, Theme, Renderer>) -> Self { + Self::new(row) + } +} diff --git a/widget/src/rule.rs b/widget/src/rule.rs index 1a536d2f..bbcd577e 100644 --- a/widget/src/rule.rs +++ b/widget/src/rule.rs @@ -1,6 +1,6 @@ //! Display a horizontal or vertical rule for dividing content. use crate::core; -use crate::core::border::{self, Border}; +use crate::core::border; use crate::core::layout; use crate::core::mouse; use crate::core::renderer; @@ -132,7 +132,7 @@ where renderer.fill_quad( renderer::Quad { bounds, - border: Border::rounded(style.radius), + border: border::rounded(style.radius), ..renderer::Quad::default() }, style.color, diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index c3d08223..af6a3945 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -1,21 +1,24 @@ //! Navigate an endless amount of content with a scrollbar. -// use crate::container; use crate::container; +use crate::core::border::{self, Border}; use crate::core::event::{self, Event}; use crate::core::keyboard; use crate::core::layout; use crate::core::mouse; use crate::core::overlay; use crate::core::renderer; +use crate::core::time::{Duration, Instant}; use crate::core::touch; use crate::core::widget; use crate::core::widget::operation::{self, Operation}; use crate::core::widget::tree::{self, Tree}; +use crate::core::window; use crate::core::{ - self, Background, Border, Clipboard, Color, Element, Layout, Length, + self, Background, Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size, Theme, Vector, Widget, }; -use crate::runtime::{Action, Task}; +use crate::runtime::task::{self, Task}; +use crate::runtime::Action; pub use operation::scrollable::{AbsoluteOffset, RelativeOffset}; @@ -52,34 +55,51 @@ where Self::with_direction(content, Direction::default()) } - /// Creates a new [`Scrollable`] with the given [`Direction`]. + /// Creates a new vertical [`Scrollable`]. pub fn with_direction( content: impl Into<Element<'a, Message, Theme, Renderer>>, - direction: Direction, + direction: impl Into<Direction>, ) -> Self { - let content = content.into(); + Scrollable { + id: None, + width: Length::Shrink, + height: Length::Shrink, + direction: direction.into(), + content: content.into(), + on_scroll: None, + class: Theme::default(), + } + .validate() + } + + fn validate(mut self) -> Self { + let size_hint = self.content.as_widget().size_hint(); debug_assert!( - direction.vertical().is_none() - || !content.as_widget().size_hint().height.is_fill(), + self.direction.vertical().is_none() || !size_hint.height.is_fill(), "scrollable content must not fill its vertical scrolling axis" ); debug_assert!( - direction.horizontal().is_none() - || !content.as_widget().size_hint().width.is_fill(), + self.direction.horizontal().is_none() || !size_hint.width.is_fill(), "scrollable content must not fill its horizontal scrolling axis" ); - Scrollable { - id: None, - width: Length::Shrink, - height: Length::Shrink, - direction, - content, - on_scroll: None, - class: Theme::default(), + if self.direction.horizontal().is_none() { + self.width = self.width.enclose(size_hint.width); } + + if self.direction.vertical().is_none() { + self.height = self.height.enclose(size_hint.height); + } + + self + } + + /// Creates a new [`Scrollable`] with the given [`Direction`]. + pub fn direction(mut self, direction: impl Into<Direction>) -> Self { + self.direction = direction.into(); + self.validate() } /// Sets the [`Id`] of the [`Scrollable`]. @@ -108,6 +128,69 @@ where self } + /// Anchors the vertical [`Scrollable`] direction to the top. + pub fn anchor_top(self) -> Self { + self.anchor_y(Anchor::Start) + } + + /// Anchors the vertical [`Scrollable`] direction to the bottom. + pub fn anchor_bottom(self) -> Self { + self.anchor_y(Anchor::End) + } + + /// Anchors the horizontal [`Scrollable`] direction to the left. + pub fn anchor_left(self) -> Self { + self.anchor_x(Anchor::Start) + } + + /// Anchors the horizontal [`Scrollable`] direction to the right. + pub fn anchor_right(self) -> Self { + self.anchor_x(Anchor::End) + } + + /// Sets the [`Anchor`] of the horizontal direction of the [`Scrollable`], if applicable. + pub fn anchor_x(mut self, alignment: Anchor) -> Self { + match &mut self.direction { + Direction::Horizontal(horizontal) + | Direction::Both { horizontal, .. } => { + horizontal.alignment = alignment; + } + Direction::Vertical { .. } => {} + } + + self + } + + /// Sets the [`Anchor`] of the vertical direction of the [`Scrollable`], if applicable. + pub fn anchor_y(mut self, alignment: Anchor) -> Self { + match &mut self.direction { + Direction::Vertical(vertical) + | Direction::Both { vertical, .. } => { + vertical.alignment = alignment; + } + Direction::Horizontal { .. } => {} + } + + self + } + + /// Embeds the [`Scrollbar`] into the [`Scrollable`], instead of floating on top of the + /// content. + /// + /// The `spacing` provided will be used as space between the [`Scrollbar`] and the contents + /// of the [`Scrollable`]. + pub fn spacing(mut self, new_spacing: impl Into<Pixels>) -> Self { + match &mut self.direction { + Direction::Horizontal(scrollbar) + | Direction::Vertical(scrollbar) => { + scrollbar.spacing = Some(new_spacing.into().0); + } + Direction::Both { .. } => {} + } + + self + } + /// Sets the style of this [`Scrollable`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self @@ -131,102 +214,133 @@ where #[derive(Debug, Clone, Copy, PartialEq)] pub enum Direction { /// Vertical scrolling - Vertical(Properties), + Vertical(Scrollbar), /// Horizontal scrolling - Horizontal(Properties), + Horizontal(Scrollbar), /// Both vertical and horizontal scrolling Both { /// The properties of the vertical scrollbar. - vertical: Properties, + vertical: Scrollbar, /// The properties of the horizontal scrollbar. - horizontal: Properties, + horizontal: Scrollbar, }, } impl Direction { - /// Returns the [`Properties`] of the horizontal scrollbar, if any. - pub fn horizontal(&self) -> Option<&Properties> { + /// Returns the horizontal [`Scrollbar`], if any. + pub fn horizontal(&self) -> Option<&Scrollbar> { match self { - Self::Horizontal(properties) => Some(properties), + Self::Horizontal(scrollbar) => Some(scrollbar), Self::Both { horizontal, .. } => Some(horizontal), Self::Vertical(_) => None, } } - /// Returns the [`Properties`] of the vertical scrollbar, if any. - pub fn vertical(&self) -> Option<&Properties> { + /// Returns the vertical [`Scrollbar`], if any. + pub fn vertical(&self) -> Option<&Scrollbar> { match self { - Self::Vertical(properties) => Some(properties), + Self::Vertical(scrollbar) => Some(scrollbar), Self::Both { vertical, .. } => Some(vertical), Self::Horizontal(_) => None, } } + + fn align(&self, delta: Vector) -> Vector { + let horizontal_alignment = + self.horizontal().map(|p| p.alignment).unwrap_or_default(); + + let vertical_alignment = + self.vertical().map(|p| p.alignment).unwrap_or_default(); + + let align = |alignment: Anchor, delta: f32| match alignment { + Anchor::Start => delta, + Anchor::End => -delta, + }; + + Vector::new( + align(horizontal_alignment, delta.x), + align(vertical_alignment, delta.y), + ) + } } impl Default for Direction { fn default() -> Self { - Self::Vertical(Properties::default()) + Self::Vertical(Scrollbar::default()) } } -/// Properties of a scrollbar within a [`Scrollable`]. +/// A scrollbar within a [`Scrollable`]. #[derive(Debug, Clone, Copy, PartialEq)] -pub struct Properties { +pub struct Scrollbar { width: f32, margin: f32, scroller_width: f32, - alignment: Alignment, + alignment: Anchor, + spacing: Option<f32>, } -impl Default for Properties { +impl Default for Scrollbar { fn default() -> Self { Self { width: 10.0, margin: 0.0, scroller_width: 10.0, - alignment: Alignment::Start, + alignment: Anchor::Start, + spacing: None, } } } -impl Properties { - /// Creates new [`Properties`] for use in a [`Scrollable`]. +impl Scrollbar { + /// Creates new [`Scrollbar`] for use in a [`Scrollable`]. pub fn new() -> Self { Self::default() } - /// Sets the scrollbar width of the [`Scrollable`] . + /// Sets the scrollbar width of the [`Scrollbar`] . pub fn width(mut self, width: impl Into<Pixels>) -> Self { self.width = width.into().0.max(0.0); self } - /// Sets the scrollbar margin of the [`Scrollable`] . + /// Sets the scrollbar margin of the [`Scrollbar`] . pub fn margin(mut self, margin: impl Into<Pixels>) -> Self { self.margin = margin.into().0; self } - /// Sets the scroller width of the [`Scrollable`] . + /// Sets the scroller width of the [`Scrollbar`] . pub fn scroller_width(mut self, scroller_width: impl Into<Pixels>) -> Self { self.scroller_width = scroller_width.into().0.max(0.0); self } - /// Sets the alignment of the [`Scrollable`] . - pub fn alignment(mut self, alignment: Alignment) -> Self { + /// Sets the [`Anchor`] of the [`Scrollbar`] . + pub fn anchor(mut self, alignment: Anchor) -> Self { self.alignment = alignment; self } + + /// Sets whether the [`Scrollbar`] should be embedded in the [`Scrollable`], using + /// the given spacing between itself and the contents. + /// + /// An embedded [`Scrollbar`] will always be displayed, will take layout space, + /// and will not float over the contents. + pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self { + self.spacing = Some(spacing.into().0); + self + } } -/// Alignment of the scrollable's content relative to it's [`Viewport`] in one direction. +/// The anchor of the scroller of the [`Scrollable`] relative to its [`Viewport`] +/// on a given axis. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum Alignment { - /// Content is aligned to the start of the [`Viewport`]. +pub enum Anchor { + /// Scroller is anchoer to the start of the [`Viewport`]. #[default] Start, - /// Content is aligned to the end of the [`Viewport`] + /// Content is aligned to the end of the [`Viewport`]. End, } @@ -265,29 +379,55 @@ where renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { - layout::contained(limits, self.width, self.height, |limits| { - let child_limits = layout::Limits::new( - Size::new(limits.min().width, limits.min().height), - Size::new( - if self.direction.horizontal().is_some() { - f32::INFINITY - } else { - limits.max().width - }, - if self.direction.vertical().is_some() { - f32::MAX - } else { - limits.max().height - }, - ), - ); + let (right_padding, bottom_padding) = match self.direction { + Direction::Vertical(Scrollbar { + width, + margin, + spacing: Some(spacing), + .. + }) => (width + margin * 2.0 + spacing, 0.0), + Direction::Horizontal(Scrollbar { + width, + margin, + spacing: Some(spacing), + .. + }) => (0.0, width + margin * 2.0 + spacing), + _ => (0.0, 0.0), + }; - self.content.as_widget().layout( - &mut tree.children[0], - renderer, - &child_limits, - ) - }) + layout::padded( + limits, + self.width, + self.height, + Padding { + right: right_padding, + bottom: bottom_padding, + ..Padding::ZERO + }, + |limits| { + let child_limits = layout::Limits::new( + Size::new(limits.min().width, limits.min().height), + Size::new( + if self.direction.horizontal().is_some() { + f32::INFINITY + } else { + limits.max().width + }, + if self.direction.vertical().is_some() { + f32::MAX + } else { + limits.max().height + }, + ), + ); + + self.content.as_widget().layout( + &mut tree.children[0], + renderer, + &child_limits, + ) + }, + ) } fn operate( @@ -295,7 +435,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { let state = tree.state.downcast_mut::<State>(); @@ -309,6 +449,7 @@ where state, self.id.as_ref().map(|id| &id.0), bounds, + content_bounds, translation, ); @@ -350,6 +491,24 @@ where let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = scrollbars.is_mouse_over(cursor); + if let Some(last_scrolled) = state.last_scrolled { + let clear_transaction = match event { + Event::Mouse( + mouse::Event::ButtonPressed(_) + | mouse::Event::ButtonReleased(_) + | mouse::Event::CursorLeft, + ) => true, + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + last_scrolled.elapsed() > Duration::from_millis(100) + } + _ => last_scrolled.elapsed() > Duration::from_millis(1500), + }; + + if clear_transaction { + state.last_scrolled = None; + } + } + if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at { match event { Event::Mouse(mouse::Event::CursorMoved { .. }) @@ -368,7 +527,7 @@ where content_bounds, ); - let _ = notify_on_scroll( + let _ = notify_scroll( state, &self.on_scroll, bounds, @@ -406,7 +565,7 @@ where state.y_scroller_grabbed_at = Some(scroller_grabbed_at); - let _ = notify_on_scroll( + let _ = notify_scroll( state, &self.on_scroll, bounds, @@ -439,7 +598,7 @@ where content_bounds, ); - let _ = notify_on_scroll( + let _ = notify_scroll( state, &self.on_scroll, bounds, @@ -477,7 +636,7 @@ where state.x_scroller_grabbed_at = Some(scroller_grabbed_at); - let _ = notify_on_scroll( + let _ = notify_scroll( state, &self.on_scroll, bounds, @@ -492,7 +651,11 @@ where } } - let mut event_status = { + let content_status = if state.last_scrolled.is_some() + && matches!(event, Event::Mouse(mouse::Event::WheelScrolled { .. })) + { + event::Status::Ignored + } else { let cursor = match cursor_over_scrollable { Some(cursor_position) if !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) => @@ -540,10 +703,10 @@ where state.x_scroller_grabbed_at = None; state.y_scroller_grabbed_at = None; - return event_status; + return content_status; } - if let event::Status::Captured = event_status { + if let event::Status::Captured = content_status { return event::Status::Captured; } @@ -563,23 +726,41 @@ where let delta = match delta { mouse::ScrollDelta::Lines { x, y } => { - // TODO: Configurable speed/friction (?) - let movement = if !cfg!(target_os = "macos") // macOS automatically inverts the axes when Shift is pressed - && state.keyboard_modifiers.shift() - { - Vector::new(y, x) - } else { + let is_shift_pressed = state.keyboard_modifiers.shift(); + + // macOS automatically inverts the axes when Shift is pressed + let (x, y) = + if cfg!(target_os = "macos") && is_shift_pressed { + (y, x) + } else { + (x, y) + }; + + let is_vertical = match self.direction { + Direction::Vertical(_) => true, + Direction::Horizontal(_) => false, + Direction::Both { .. } => !is_shift_pressed, + }; + + let movement = if is_vertical { Vector::new(x, y) + } else { + Vector::new(y, x) }; - movement * 60.0 + // TODO: Configurable speed/friction (?) + -movement * 60.0 } mouse::ScrollDelta::Pixels { x, y } => Vector::new(x, y), }; - state.scroll(delta, self.direction, bounds, content_bounds); + state.scroll( + self.direction.align(delta), + bounds, + content_bounds, + ); - event_status = if notify_on_scroll( + if notify_scroll( state, &self.on_scroll, bounds, @@ -589,7 +770,7 @@ where event::Status::Captured } else { event::Status::Ignored - }; + } } Event::Touch(event) if state.scroll_area_touched_at.is_some() @@ -613,13 +794,12 @@ where }; let delta = Vector::new( - cursor_position.x - scroll_box_touched_at.x, - cursor_position.y - scroll_box_touched_at.y, + scroll_box_touched_at.x - cursor_position.x, + scroll_box_touched_at.y - cursor_position.y, ); state.scroll( - delta, - self.direction, + self.direction.align(delta), bounds, content_bounds, ); @@ -628,7 +808,7 @@ where Some(cursor_position); // TODO: bubble up touch movements if not consumed. - let _ = notify_on_scroll( + let _ = notify_scroll( state, &self.on_scroll, bounds, @@ -640,12 +820,21 @@ where _ => {} } - event_status = event::Status::Captured; + event::Status::Captured } - _ => {} - } + Event::Window(window::Event::RedrawRequested(_)) => { + let _ = notify_viewport( + state, + &self.on_scroll, + bounds, + content_bounds, + shell, + ); - event_status + event::Status::Ignored + } + _ => event::Status::Ignored, + } } fn draw( @@ -736,7 +925,7 @@ where let draw_scrollbar = |renderer: &mut Renderer, - style: Scrollbar, + style: Rail, scrollbar: &internals::Scrollbar| { if scrollbar.bounds.width > 0.0 && scrollbar.bounds.height > 0.0 @@ -756,21 +945,23 @@ where ); } - if scrollbar.scroller.bounds.width > 0.0 - && scrollbar.scroller.bounds.height > 0.0 - && (style.scroller.color != Color::TRANSPARENT - || (style.scroller.border.color - != Color::TRANSPARENT - && style.scroller.border.width > 0.0)) - { - renderer.fill_quad( - renderer::Quad { - bounds: scrollbar.scroller.bounds, - border: style.scroller.border, - ..renderer::Quad::default() - }, - style.scroller.color, - ); + if let Some(scroller) = scrollbar.scroller { + if scroller.bounds.width > 0.0 + && scroller.bounds.height > 0.0 + && (style.scroller.color != Color::TRANSPARENT + || (style.scroller.border.color + != Color::TRANSPARENT + && style.scroller.border.width > 0.0)) + { + renderer.fill_quad( + renderer::Quad { + bounds: scroller.bounds, + border: style.scroller.border, + ..renderer::Quad::default() + }, + style.scroller.color, + ); + } } }; @@ -784,7 +975,7 @@ where if let Some(scrollbar) = scrollbars.y { draw_scrollbar( renderer, - style.vertical_scrollbar, + style.vertical_rail, &scrollbar, ); } @@ -792,7 +983,7 @@ where if let Some(scrollbar) = scrollbars.x { draw_scrollbar( renderer, - style.horizontal_scrollbar, + style.horizontal_rail, &scrollbar, ); } @@ -953,21 +1144,44 @@ impl From<Id> for widget::Id { } /// Produces a [`Task`] that snaps the [`Scrollable`] with the given [`Id`] -/// to the provided `percentage` along the x & y axis. +/// to the provided [`RelativeOffset`]. pub fn snap_to<T>(id: Id, offset: RelativeOffset) -> Task<T> { - Task::effect(Action::widget(operation::scrollable::snap_to(id.0, offset))) + task::effect(Action::widget(operation::scrollable::snap_to(id.0, offset))) } /// Produces a [`Task`] that scrolls the [`Scrollable`] with the given [`Id`] -/// to the provided [`AbsoluteOffset`] along the x & y axis. +/// to the provided [`AbsoluteOffset`]. pub fn scroll_to<T>(id: Id, offset: AbsoluteOffset) -> Task<T> { - Task::effect(Action::widget(operation::scrollable::scroll_to( + task::effect(Action::widget(operation::scrollable::scroll_to( id.0, offset, ))) } -/// Returns [`true`] if the viewport actually changed. -fn notify_on_scroll<Message>( +/// Produces a [`Task`] that scrolls the [`Scrollable`] with the given [`Id`] +/// by the provided [`AbsoluteOffset`]. +pub fn scroll_by<T>(id: Id, offset: AbsoluteOffset) -> Task<T> { + task::effect(Action::widget(operation::scrollable::scroll_by( + id.0, offset, + ))) +} + +fn notify_scroll<Message>( + state: &mut State, + on_scroll: &Option<Box<dyn Fn(Viewport) -> Message + '_>>, + bounds: Rectangle, + content_bounds: Rectangle, + shell: &mut Shell<'_, Message>, +) -> bool { + if notify_viewport(state, on_scroll, bounds, content_bounds, shell) { + state.last_scrolled = Some(Instant::now()); + + true + } else { + false + } +} + +fn notify_viewport<Message>( state: &mut State, on_scroll: &Option<Box<dyn Fn(Viewport) -> Message + '_>>, bounds: Rectangle, @@ -980,6 +1194,11 @@ fn notify_on_scroll<Message>( return false; } + let Some(on_scroll) = on_scroll else { + state.last_notified = None; + return false; + }; + let viewport = Viewport { offset_x: state.offset_x, offset_y: state.offset_y, @@ -999,7 +1218,9 @@ fn notify_on_scroll<Message>( (a - b).abs() <= f32::EPSILON || (a.is_nan() && b.is_nan()) }; - if unchanged(last_relative_offset.x, current_relative_offset.x) + if last_notified.bounds == bounds + && last_notified.content_bounds == content_bounds + && unchanged(last_relative_offset.x, current_relative_offset.x) && unchanged(last_relative_offset.y, current_relative_offset.y) && unchanged(last_absolute_offset.x, current_absolute_offset.x) && unchanged(last_absolute_offset.y, current_absolute_offset.y) @@ -1008,9 +1229,7 @@ fn notify_on_scroll<Message>( } } - if let Some(on_scroll) = on_scroll { - shell.publish(on_scroll(viewport)); - } + shell.publish(on_scroll(viewport)); state.last_notified = Some(viewport); true @@ -1025,6 +1244,7 @@ struct State { x_scroller_grabbed_at: Option<f32>, keyboard_modifiers: keyboard::Modifiers, last_notified: Option<Viewport>, + last_scrolled: Option<Instant>, } impl Default for State { @@ -1037,6 +1257,7 @@ impl Default for State { x_scroller_grabbed_at: None, keyboard_modifiers: keyboard::Modifiers::default(), last_notified: None, + last_scrolled: None, } } } @@ -1049,6 +1270,15 @@ impl operation::Scrollable for State { fn scroll_to(&mut self, offset: AbsoluteOffset) { State::scroll_to(self, offset); } + + fn scroll_by( + &mut self, + offset: AbsoluteOffset, + bounds: Rectangle, + content_bounds: Rectangle, + ) { + State::scroll_by(self, offset, bounds, content_bounds); + } } #[derive(Debug, Clone, Copy)] @@ -1073,13 +1303,13 @@ impl Offset { self, viewport: f32, content: f32, - alignment: Alignment, + alignment: Anchor, ) -> f32 { let offset = self.absolute(viewport, content); match alignment { - Alignment::Start => offset, - Alignment::End => ((content - viewport).max(0.0) - offset).max(0.0), + Anchor::Start => offset, + Anchor::End => ((content - viewport).max(0.0) - offset).max(0.0), } } } @@ -1152,34 +1382,13 @@ impl State { pub fn scroll( &mut self, delta: Vector<f32>, - direction: Direction, bounds: Rectangle, content_bounds: Rectangle, ) { - let horizontal_alignment = direction - .horizontal() - .map(|p| p.alignment) - .unwrap_or_default(); - - let vertical_alignment = direction - .vertical() - .map(|p| p.alignment) - .unwrap_or_default(); - - let align = |alignment: Alignment, delta: f32| match alignment { - Alignment::Start => delta, - Alignment::End => -delta, - }; - - let delta = Vector::new( - align(horizontal_alignment, delta.x), - align(vertical_alignment, delta.y), - ); - if bounds.height < content_bounds.height { self.offset_y = Offset::Absolute( (self.offset_y.absolute(bounds.height, content_bounds.height) - - delta.y) + + delta.y) .clamp(0.0, content_bounds.height - bounds.height), ); } @@ -1187,7 +1396,7 @@ impl State { if bounds.width < content_bounds.width { self.offset_x = Offset::Absolute( (self.offset_x.absolute(bounds.width, content_bounds.width) - - delta.x) + + delta.x) .clamp(0.0, content_bounds.width - bounds.width), ); } @@ -1233,6 +1442,16 @@ impl State { self.offset_y = Offset::Absolute(offset.y.max(0.0)); } + /// Scroll by the provided [`AbsoluteOffset`]. + pub fn scroll_by( + &mut self, + offset: AbsoluteOffset, + bounds: Rectangle, + content_bounds: Rectangle, + ) { + self.scroll(Vector::new(offset.x, offset.y), bounds, content_bounds); + } + /// Unsnaps the current scroll position, if snapped, given the bounds of the /// [`Scrollable`] and its contents. pub fn unsnap(&mut self, bounds: Rectangle, content_bounds: Rectangle) { @@ -1298,16 +1517,16 @@ impl Scrollbars { ) -> Self { let translation = state.translation(direction, bounds, content_bounds); - let show_scrollbar_x = direction - .horizontal() - .filter(|_| content_bounds.width > bounds.width); + let show_scrollbar_x = direction.horizontal().filter(|scrollbar| { + scrollbar.spacing.is_some() || content_bounds.width > bounds.width + }); - let show_scrollbar_y = direction - .vertical() - .filter(|_| content_bounds.height > bounds.height); + let show_scrollbar_y = direction.vertical().filter(|scrollbar| { + scrollbar.spacing.is_some() || content_bounds.height > bounds.height + }); let y_scrollbar = if let Some(vertical) = show_scrollbar_y { - let Properties { + let Scrollbar { width, margin, scroller_width, @@ -1341,26 +1560,35 @@ impl Scrollbars { }; let ratio = bounds.height / content_bounds.height; - // min height for easier grabbing with super tall content - let scroller_height = (scrollbar_bounds.height * ratio).max(2.0); - let scroller_offset = - translation.y * ratio * scrollbar_bounds.height / bounds.height; - let scroller_bounds = Rectangle { - x: bounds.x + bounds.width - - total_scrollbar_width / 2.0 - - scroller_width / 2.0, - y: (scrollbar_bounds.y + scroller_offset).max(0.0), - width: scroller_width, - height: scroller_height, + let scroller = if ratio >= 1.0 { + None + } else { + // min height for easier grabbing with super tall content + let scroller_height = + (scrollbar_bounds.height * ratio).max(2.0); + let scroller_offset = + translation.y * ratio * scrollbar_bounds.height + / bounds.height; + + let scroller_bounds = Rectangle { + x: bounds.x + bounds.width + - total_scrollbar_width / 2.0 + - scroller_width / 2.0, + y: (scrollbar_bounds.y + scroller_offset).max(0.0), + width: scroller_width, + height: scroller_height, + }; + + Some(internals::Scroller { + bounds: scroller_bounds, + }) }; Some(internals::Scrollbar { total_bounds: total_scrollbar_bounds, bounds: scrollbar_bounds, - scroller: internals::Scroller { - bounds: scroller_bounds, - }, + scroller, alignment: vertical.alignment, }) } else { @@ -1368,7 +1596,7 @@ impl Scrollbars { }; let x_scrollbar = if let Some(horizontal) = show_scrollbar_x { - let Properties { + let Scrollbar { width, margin, scroller_width, @@ -1402,26 +1630,34 @@ impl Scrollbars { }; let ratio = bounds.width / content_bounds.width; - // min width for easier grabbing with extra wide content - let scroller_length = (scrollbar_bounds.width * ratio).max(2.0); - let scroller_offset = - translation.x * ratio * scrollbar_bounds.width / bounds.width; - let scroller_bounds = Rectangle { - x: (scrollbar_bounds.x + scroller_offset).max(0.0), - y: bounds.y + bounds.height - - total_scrollbar_height / 2.0 - - scroller_width / 2.0, - width: scroller_length, - height: scroller_width, + let scroller = if ratio >= 1.0 { + None + } else { + // min width for easier grabbing with extra wide content + let scroller_length = (scrollbar_bounds.width * ratio).max(2.0); + let scroller_offset = + translation.x * ratio * scrollbar_bounds.width + / bounds.width; + + let scroller_bounds = Rectangle { + x: (scrollbar_bounds.x + scroller_offset).max(0.0), + y: bounds.y + bounds.height + - total_scrollbar_height / 2.0 + - scroller_width / 2.0, + width: scroller_length, + height: scroller_width, + }; + + Some(internals::Scroller { + bounds: scroller_bounds, + }) }; Some(internals::Scrollbar { total_bounds: total_scrollbar_bounds, bounds: scrollbar_bounds, - scroller: internals::Scroller { - bounds: scroller_bounds, - }, + scroller, alignment: horizontal.alignment, }) } else { @@ -1452,33 +1688,33 @@ impl Scrollbars { } fn grab_y_scroller(&self, cursor_position: Point) -> Option<f32> { - self.y.and_then(|scrollbar| { - if scrollbar.total_bounds.contains(cursor_position) { - Some(if scrollbar.scroller.bounds.contains(cursor_position) { - (cursor_position.y - scrollbar.scroller.bounds.y) - / scrollbar.scroller.bounds.height - } else { - 0.5 - }) + let scrollbar = self.y?; + let scroller = scrollbar.scroller?; + + if scrollbar.total_bounds.contains(cursor_position) { + Some(if scroller.bounds.contains(cursor_position) { + (cursor_position.y - scroller.bounds.y) / scroller.bounds.height } else { - None - } - }) + 0.5 + }) + } else { + None + } } fn grab_x_scroller(&self, cursor_position: Point) -> Option<f32> { - self.x.and_then(|scrollbar| { - if scrollbar.total_bounds.contains(cursor_position) { - Some(if scrollbar.scroller.bounds.contains(cursor_position) { - (cursor_position.x - scrollbar.scroller.bounds.x) - / scrollbar.scroller.bounds.width - } else { - 0.5 - }) + let scrollbar = self.x?; + let scroller = scrollbar.scroller?; + + if scrollbar.total_bounds.contains(cursor_position) { + Some(if scroller.bounds.contains(cursor_position) { + (cursor_position.x - scroller.bounds.x) / scroller.bounds.width } else { - None - } - }) + 0.5 + }) + } else { + None + } } fn active(&self) -> bool { @@ -1489,14 +1725,14 @@ impl Scrollbars { pub(super) mod internals { use crate::core::{Point, Rectangle}; - use super::Alignment; + use super::Anchor; #[derive(Debug, Copy, Clone)] pub struct Scrollbar { pub total_bounds: Rectangle, pub bounds: Rectangle, - pub scroller: Scroller, - pub alignment: Alignment, + pub scroller: Option<Scroller>, + pub alignment: Anchor, } impl Scrollbar { @@ -1511,14 +1747,18 @@ pub(super) mod internals { grabbed_at: f32, cursor_position: Point, ) -> f32 { - let percentage = (cursor_position.y - - self.bounds.y - - self.scroller.bounds.height * grabbed_at) - / (self.bounds.height - self.scroller.bounds.height); - - match self.alignment { - Alignment::Start => percentage, - Alignment::End => 1.0 - percentage, + if let Some(scroller) = self.scroller { + let percentage = (cursor_position.y + - self.bounds.y + - scroller.bounds.height * grabbed_at) + / (self.bounds.height - scroller.bounds.height); + + match self.alignment { + Anchor::Start => percentage, + Anchor::End => 1.0 - percentage, + } + } else { + 0.0 } } @@ -1528,14 +1768,18 @@ pub(super) mod internals { grabbed_at: f32, cursor_position: Point, ) -> f32 { - let percentage = (cursor_position.x - - self.bounds.x - - self.scroller.bounds.width * grabbed_at) - / (self.bounds.width - self.scroller.bounds.width); - - match self.alignment { - Alignment::Start => percentage, - Alignment::End => 1.0 - percentage, + if let Some(scroller) = self.scroller { + let percentage = (cursor_position.x + - self.bounds.x + - scroller.bounds.width * grabbed_at) + / (self.bounds.width - scroller.bounds.width); + + match self.alignment { + Anchor::Start => percentage, + Anchor::End => 1.0 - percentage, + } + } else { + 0.0 } } } @@ -1569,22 +1813,22 @@ pub enum Status { }, } -/// The appearance of a scrolable. +/// The appearance of a scrollable. #[derive(Debug, Clone, Copy)] pub struct Style { /// The [`container::Style`] of a scrollable. pub container: container::Style, - /// The vertical [`Scrollbar`] appearance. - pub vertical_scrollbar: Scrollbar, - /// The horizontal [`Scrollbar`] appearance. - pub horizontal_scrollbar: Scrollbar, + /// The vertical [`Rail`] appearance. + pub vertical_rail: Rail, + /// The horizontal [`Rail`] appearance. + pub horizontal_rail: Rail, /// The [`Background`] of the gap between a horizontal and vertical scrollbar. pub gap: Option<Background>, } /// The appearance of the scrollbar of a scrollable. #[derive(Debug, Clone, Copy)] -pub struct Scrollbar { +pub struct Rail { /// The [`Background`] of a scrollbar. pub background: Option<Background>, /// The [`Border`] of a scrollbar. @@ -1633,27 +1877,27 @@ impl Catalog for Theme { pub fn default(theme: &Theme, status: Status) -> Style { let palette = theme.extended_palette(); - let scrollbar = Scrollbar { + let scrollbar = Rail { background: Some(palette.background.weak.color.into()), - border: Border::rounded(2), + border: border::rounded(2), scroller: Scroller { color: palette.background.strong.color, - border: Border::rounded(2), + border: border::rounded(2), }, }; match status { Status::Active => Style { container: container::Style::default(), - vertical_scrollbar: scrollbar, - horizontal_scrollbar: scrollbar, + vertical_rail: scrollbar, + horizontal_rail: scrollbar, gap: None, }, Status::Hovered { is_horizontal_scrollbar_hovered, is_vertical_scrollbar_hovered, } => { - let hovered_scrollbar = Scrollbar { + let hovered_scrollbar = Rail { scroller: Scroller { color: palette.primary.strong.color, ..scrollbar.scroller @@ -1663,12 +1907,12 @@ pub fn default(theme: &Theme, status: Status) -> Style { Style { container: container::Style::default(), - vertical_scrollbar: if is_vertical_scrollbar_hovered { + vertical_rail: if is_vertical_scrollbar_hovered { hovered_scrollbar } else { scrollbar }, - horizontal_scrollbar: if is_horizontal_scrollbar_hovered { + horizontal_rail: if is_horizontal_scrollbar_hovered { hovered_scrollbar } else { scrollbar @@ -1680,7 +1924,7 @@ pub fn default(theme: &Theme, status: Status) -> Style { is_horizontal_scrollbar_dragged, is_vertical_scrollbar_dragged, } => { - let dragged_scrollbar = Scrollbar { + let dragged_scrollbar = Rail { scroller: Scroller { color: palette.primary.base.color, ..scrollbar.scroller @@ -1690,12 +1934,12 @@ pub fn default(theme: &Theme, status: Status) -> Style { Style { container: container::Style::default(), - vertical_scrollbar: if is_vertical_scrollbar_dragged { + vertical_rail: if is_vertical_scrollbar_dragged { dragged_scrollbar } else { scrollbar }, - horizontal_scrollbar: if is_horizontal_scrollbar_dragged { + horizontal_rail: if is_horizontal_scrollbar_dragged { dragged_scrollbar } else { scrollbar diff --git a/widget/src/slider.rs b/widget/src/slider.rs index a8f1d192..aebf68e2 100644 --- a/widget/src/slider.rs +++ b/widget/src/slider.rs @@ -1,5 +1,5 @@ //! Display an interactive selector of a single value from a range of values. -use crate::core::border; +use crate::core::border::{self, Border}; use crate::core::event::{self, Event}; use crate::core::keyboard; use crate::core::keyboard::key::{self, Key}; @@ -9,7 +9,7 @@ use crate::core::renderer; use crate::core::touch; use crate::core::widget::tree::{self, Tree}; use crate::core::{ - self, Border, Clipboard, Color, Element, Layout, Length, Pixels, Point, + self, Background, Clipboard, Color, Element, Layout, Length, Pixels, Point, Rectangle, Shell, Size, Theme, Widget, }; @@ -70,8 +70,8 @@ where /// * an inclusive range of possible values /// * the current value of the [`Slider`] /// * a function that will be called when the [`Slider`] is dragged. - /// It receives the new value of the [`Slider`] and must produce a - /// `Message`. + /// It receives the new value of the [`Slider`] and must produce a + /// `Message`. pub fn new<F>(range: RangeInclusive<T>, value: T, on_change: F) -> Self where F: 'a + Fn(T) -> Message, @@ -237,7 +237,7 @@ where let steps = (percent * (end - start) / step).round(); let value = steps * step + start; - T::from_f64(value) + T::from_f64(value.min(end)) }; new_value @@ -408,10 +408,10 @@ where width: offset + handle_width / 2.0, height: style.rail.width, }, - border: Border::rounded(style.rail.border_radius), + border: style.rail.border, ..renderer::Quad::default() }, - style.rail.colors.0, + style.rail.backgrounds.0, ); renderer.fill_quad( @@ -422,10 +422,10 @@ where width: bounds.width - offset - handle_width / 2.0, height: style.rail.width, }, - border: Border::rounded(style.rail.border_radius), + border: style.rail.border, ..renderer::Quad::default() }, - style.rail.colors.1, + style.rail.backgrounds.1, ); renderer.fill_quad( @@ -443,7 +443,7 @@ where }, ..renderer::Quad::default() }, - style.handle.color, + style.handle.background, ); } @@ -524,12 +524,12 @@ impl Style { /// The appearance of a slider rail #[derive(Debug, Clone, Copy)] pub struct Rail { - /// The colors of the rail of the slider. - pub colors: (Color, Color), + /// The backgrounds of the rail of the slider. + pub backgrounds: (Background, Background), /// The width of the stroke of a slider rail. pub width: f32, - /// The border radius of the corners of the rail. - pub border_radius: border::Radius, + /// The border of the rail. + pub border: Border, } /// The appearance of the handle of a slider. @@ -537,8 +537,8 @@ pub struct Rail { pub struct Handle { /// The shape of the handle. pub shape: HandleShape, - /// The [`Color`] of the handle. - pub color: Color, + /// The [`Background`] of the handle. + pub background: Background, /// The border width of the handle. pub border_width: f32, /// The border [`Color`] of the handle. @@ -601,13 +601,17 @@ pub fn default(theme: &Theme, status: Status) -> Style { Style { rail: Rail { - colors: (color, palette.secondary.base.color), + backgrounds: (color.into(), palette.secondary.base.color.into()), width: 4.0, - border_radius: 2.0.into(), + border: Border { + radius: 2.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, }, handle: Handle { shape: HandleShape::Circle { radius: 7.0 }, - color, + background: color.into(), border_color: Color::TRANSPARENT, border_width: 0.0, }, diff --git a/widget/src/stack.rs b/widget/src/stack.rs index efa9711d..9ccaa274 100644 --- a/widget/src/stack.rs +++ b/widget/src/stack.rs @@ -189,7 +189,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { operation.container(None, layout.bounds(), &mut |operation| { self.children @@ -209,19 +209,23 @@ where tree: &mut Tree, event: Event, layout: Layout<'_>, - cursor: mouse::Cursor, + mut cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) -> event::Status { + let is_over_scroll = + matches!(event, Event::Mouse(mouse::Event::WheelScrolled { .. })) + && cursor.is_over(layout.bounds()); + self.children .iter_mut() .rev() .zip(tree.children.iter_mut().rev()) .zip(layout.children().rev()) .map(|((child, state), layout)| { - child.as_widget_mut().on_event( + let status = child.as_widget_mut().on_event( state, event.clone(), layout, @@ -230,7 +234,19 @@ where clipboard, shell, viewport, - ) + ); + + if is_over_scroll && cursor != mouse::Cursor::Unavailable { + let interaction = child.as_widget().mouse_interaction( + state, layout, cursor, viewport, renderer, + ); + + if interaction != mouse::Interaction::None { + cursor = mouse::Cursor::Unavailable; + } + } + + status }) .find(|&status| status == event::Status::Captured) .unwrap_or(event::Status::Ignored) @@ -269,15 +285,53 @@ where viewport: &Rectangle, ) { if let Some(clipped_viewport) = layout.bounds().intersection(viewport) { - for (i, ((layer, state), layout)) in self + let layers_below = if cursor.is_over(layout.bounds()) { + self.children + .iter() + .rev() + .zip(tree.children.iter().rev()) + .zip(layout.children().rev()) + .position(|((layer, state), layout)| { + let interaction = layer.as_widget().mouse_interaction( + state, layout, cursor, viewport, renderer, + ); + + interaction != mouse::Interaction::None + }) + .map(|i| self.children.len() - i - 1) + .unwrap_or_default() + } else { + 0 + }; + + let mut layers = self .children .iter() .zip(&tree.children) .zip(layout.children()) - .enumerate() - { - if i > 0 { - renderer.with_layer(clipped_viewport, |renderer| { + .enumerate(); + + let layers = layers.by_ref(); + + let mut draw_layer = + |i, + layer: &Element<'a, Message, Theme, Renderer>, + state, + layout, + cursor| { + if i > 0 { + renderer.with_layer(clipped_viewport, |renderer| { + layer.as_widget().draw( + state, + renderer, + theme, + style, + layout, + cursor, + &clipped_viewport, + ); + }); + } else { layer.as_widget().draw( state, renderer, @@ -287,18 +341,15 @@ where cursor, &clipped_viewport, ); - }); - } else { - layer.as_widget().draw( - state, - renderer, - theme, - style, - layout, - cursor, - &clipped_viewport, - ); - } + } + }; + + for (i, ((layer, state), layout)) in layers.take(layers_below) { + draw_layer(i, layer, state, layout, mouse::Cursor::Unavailable); + } + + for (i, ((layer, state), layout)) in layers { + draw_layer(i, layer, state, layout, cursor); } } } diff --git a/widget/src/svg.rs b/widget/src/svg.rs index 4551bcad..bec0090f 100644 --- a/widget/src/svg.rs +++ b/widget/src/svg.rs @@ -211,11 +211,13 @@ where let render = |renderer: &mut Renderer| { renderer.draw_svg( - self.handle.clone(), - style.color, + svg::Svg { + handle: self.handle.clone(), + color: style.color, + rotation: self.rotation.radians(), + opacity: self.opacity, + }, drawing_bounds, - self.rotation.radians(), - self.opacity, ); }; diff --git a/widget/src/text.rs b/widget/src/text.rs index 0d689295..9bf7fce4 100644 --- a/widget/src/text.rs +++ b/widget/src/text.rs @@ -1,5 +1,9 @@ //! Draw and interact with text. +mod rich; + +pub use crate::core::text::{Fragment, Highlighter, IntoFragment, Span}; pub use crate::core::widget::text::*; +pub use rich::Rich; /// A paragraph. pub type Text<'a, Theme = crate::Theme, Renderer = crate::Renderer> = diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs new file mode 100644 index 00000000..921c55a5 --- /dev/null +++ b/widget/src/text/rich.rs @@ -0,0 +1,538 @@ +use crate::core::alignment; +use crate::core::event; +use crate::core::layout; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::text::{Paragraph, Span}; +use crate::core::widget::text::{ + self, Catalog, LineHeight, Shaping, Style, StyleFn, Wrapping, +}; +use crate::core::widget::tree::{self, Tree}; +use crate::core::{ + self, Clipboard, Color, Element, Event, Layout, Length, Pixels, Point, + Rectangle, Shell, Size, Vector, Widget, +}; + +/// A bunch of [`Rich`] text. +#[allow(missing_debug_implementations)] +pub struct Rich<'a, Link, Theme = crate::Theme, Renderer = crate::Renderer> +where + Link: Clone + 'static, + Theme: Catalog, + Renderer: core::text::Renderer, +{ + spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>, + size: Option<Pixels>, + line_height: LineHeight, + width: Length, + height: Length, + font: Option<Renderer::Font>, + align_x: alignment::Horizontal, + align_y: alignment::Vertical, + wrapping: Wrapping, + class: Theme::Class<'a>, +} + +impl<'a, Link, Theme, Renderer> Rich<'a, Link, Theme, Renderer> +where + Link: Clone + 'static, + Theme: Catalog, + Renderer: core::text::Renderer, + Renderer::Font: 'a, +{ + /// Creates a new empty [`Rich`] text. + pub fn new() -> Self { + Self { + spans: Box::new([]), + size: None, + line_height: LineHeight::default(), + width: Length::Shrink, + height: Length::Shrink, + font: None, + align_x: alignment::Horizontal::Left, + align_y: alignment::Vertical::Top, + wrapping: Wrapping::default(), + class: Theme::default(), + } + } + + /// Creates a new [`Rich`] text with the given text spans. + pub fn with_spans( + spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a, + ) -> Self { + Self { + spans: Box::new(spans), + ..Self::new() + } + } + + /// Sets the default size of the [`Rich`] text. + pub fn size(mut self, size: impl Into<Pixels>) -> Self { + self.size = Some(size.into()); + self + } + + /// Sets the defualt [`LineHeight`] of the [`Rich`] text. + pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self { + self.line_height = line_height.into(); + self + } + + /// Sets the default font of the [`Rich`] text. + pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self { + self.font = Some(font.into()); + self + } + + /// Sets the width of the [`Rich`] text boundaries. + pub fn width(mut self, width: impl Into<Length>) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Rich`] text boundaries. + pub fn height(mut self, height: impl Into<Length>) -> Self { + self.height = height.into(); + self + } + + /// Centers the [`Rich`] text, both horizontally and vertically. + pub fn center(self) -> Self { + self.align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + } + + /// Sets the [`alignment::Horizontal`] of the [`Rich`] text. + pub fn align_x( + mut self, + alignment: impl Into<alignment::Horizontal>, + ) -> Self { + self.align_x = alignment.into(); + self + } + + /// Sets the [`alignment::Vertical`] of the [`Rich`] text. + pub fn align_y( + mut self, + alignment: impl Into<alignment::Vertical>, + ) -> Self { + self.align_y = alignment.into(); + self + } + + /// Sets the [`Wrapping`] strategy of the [`Rich`] text. + pub fn wrapping(mut self, wrapping: Wrapping) -> Self { + self.wrapping = wrapping; + self + } + + /// Sets the default style of the [`Rich`] text. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self + where + Theme::Class<'a>: From<StyleFn<'a, Theme>>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the default [`Color`] of the [`Rich`] text. + pub fn color(self, color: impl Into<Color>) -> Self + where + Theme::Class<'a>: From<StyleFn<'a, Theme>>, + { + self.color_maybe(Some(color)) + } + + /// Sets the default [`Color`] of the [`Rich`] text, if `Some`. + pub fn color_maybe(self, color: Option<impl Into<Color>>) -> Self + where + Theme::Class<'a>: From<StyleFn<'a, Theme>>, + { + let color = color.map(Into::into); + + self.style(move |_theme| Style { color }) + } + + /// Sets the default style class of the [`Rich`] text. + #[cfg(feature = "advanced")] + #[must_use] + pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self { + self.class = class.into(); + self + } +} + +impl<'a, Link, Theme, Renderer> Default for Rich<'a, Link, Theme, Renderer> +where + Link: Clone + 'a, + Theme: Catalog, + Renderer: core::text::Renderer, + Renderer::Font: 'a, +{ + fn default() -> Self { + Self::new() + } +} + +struct State<Link, P: Paragraph> { + spans: Vec<Span<'static, Link, P::Font>>, + span_pressed: Option<usize>, + paragraph: P, +} + +impl<'a, Link, Theme, Renderer> Widget<Link, Theme, Renderer> + for Rich<'a, Link, Theme, Renderer> +where + Link: Clone + 'static, + Theme: Catalog, + Renderer: core::text::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<State<Link, Renderer::Paragraph>>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::<Link, _> { + spans: Vec::new(), + span_pressed: None, + paragraph: Renderer::Paragraph::default(), + }) + } + + fn size(&self) -> Size<Length> { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout( + tree.state + .downcast_mut::<State<Link, Renderer::Paragraph>>(), + renderer, + limits, + self.width, + self.height, + self.spans.as_ref().as_ref(), + self.line_height, + self.size, + self.font, + self.align_x, + self.align_y, + self.wrapping, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let state = tree + .state + .downcast_ref::<State<Link, Renderer::Paragraph>>(); + + let style = theme.style(&self.class); + + let hovered_span = cursor + .position_in(layout.bounds()) + .and_then(|position| state.paragraph.hit_span(position)); + + for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() { + let is_hovered_link = + span.link.is_some() && Some(index) == hovered_span; + + if span.highlight.is_some() + || span.underline + || span.strikethrough + || is_hovered_link + { + let translation = layout.position() - Point::ORIGIN; + let regions = state.paragraph.span_bounds(index); + + if let Some(highlight) = span.highlight { + for bounds in ®ions { + let bounds = Rectangle::new( + bounds.position() + - Vector::new( + span.padding.left, + span.padding.top, + ), + bounds.size() + + Size::new( + span.padding.horizontal(), + span.padding.vertical(), + ), + ); + + renderer.fill_quad( + renderer::Quad { + bounds: bounds + translation, + border: highlight.border, + ..Default::default() + }, + highlight.background, + ); + } + } + + if span.underline || span.strikethrough || is_hovered_link { + let size = span + .size + .or(self.size) + .unwrap_or(renderer.default_size()); + + let line_height = span + .line_height + .unwrap_or(self.line_height) + .to_absolute(size); + + let color = span + .color + .or(style.color) + .unwrap_or(defaults.text_color); + + let baseline = translation + + Vector::new( + 0.0, + size.0 + (line_height.0 - size.0) / 2.0, + ); + + if span.underline || is_hovered_link { + for bounds in ®ions { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + bounds.position() + baseline + - Vector::new(0.0, size.0 * 0.08), + Size::new(bounds.width, 1.0), + ), + ..Default::default() + }, + color, + ); + } + } + + if span.strikethrough { + for bounds in ®ions { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + bounds.position() + baseline + - Vector::new(0.0, size.0 / 2.0), + Size::new(bounds.width, 1.0), + ), + ..Default::default() + }, + color, + ); + } + } + } + } + } + + text::draw( + renderer, + defaults, + layout, + &state.paragraph, + style, + viewport, + ); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Link>, + _viewport: &Rectangle, + ) -> event::Status { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(position) = cursor.position_in(layout.bounds()) { + let state = tree + .state + .downcast_mut::<State<Link, Renderer::Paragraph>>(); + + if let Some(span) = state.paragraph.hit_span(position) { + state.span_pressed = Some(span); + + return event::Status::Captured; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let state = tree + .state + .downcast_mut::<State<Link, Renderer::Paragraph>>(); + + if let Some(span_pressed) = state.span_pressed { + state.span_pressed = None; + + if let Some(position) = cursor.position_in(layout.bounds()) + { + match state.paragraph.hit_span(position) { + Some(span) if span == span_pressed => { + if let Some(link) = self + .spans + .as_ref() + .as_ref() + .get(span) + .and_then(|span| span.link.clone()) + { + shell.publish(link); + } + } + _ => {} + } + } + } + } + _ => {} + } + + event::Status::Ignored + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if let Some(position) = cursor.position_in(layout.bounds()) { + let state = tree + .state + .downcast_ref::<State<Link, Renderer::Paragraph>>(); + + if let Some(span) = state + .paragraph + .hit_span(position) + .and_then(|span| self.spans.as_ref().as_ref().get(span)) + { + if span.link.is_some() { + return mouse::Interaction::Pointer; + } + } + } + + mouse::Interaction::None + } +} + +fn layout<Link, Renderer>( + state: &mut State<Link, Renderer::Paragraph>, + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + height: Length, + spans: &[Span<'_, Link, Renderer::Font>], + line_height: LineHeight, + size: Option<Pixels>, + font: Option<Renderer::Font>, + horizontal_alignment: alignment::Horizontal, + vertical_alignment: alignment::Vertical, + wrapping: Wrapping, +) -> layout::Node +where + Link: Clone, + Renderer: core::text::Renderer, +{ + layout::sized(limits, width, height, |limits| { + let bounds = limits.max(); + + let size = size.unwrap_or_else(|| renderer.default_size()); + let font = font.unwrap_or_else(|| renderer.default_font()); + + let text_with_spans = || core::Text { + content: spans, + bounds, + size, + line_height, + font, + horizontal_alignment, + vertical_alignment, + shaping: Shaping::Advanced, + wrapping, + }; + + if state.spans != spans { + state.paragraph = + Renderer::Paragraph::with_spans(text_with_spans()); + state.spans = spans.iter().cloned().map(Span::to_static).collect(); + } else { + match state.paragraph.compare(core::Text { + content: (), + bounds, + size, + line_height, + font, + horizontal_alignment, + vertical_alignment, + shaping: Shaping::Advanced, + wrapping, + }) { + core::text::Difference::None => {} + core::text::Difference::Bounds => { + state.paragraph.resize(bounds); + } + core::text::Difference::Shape => { + state.paragraph = + Renderer::Paragraph::with_spans(text_with_spans()); + } + } + } + + state.paragraph.min_bounds() + }) +} + +impl<'a, Link, Theme, Renderer> FromIterator<Span<'a, Link, Renderer::Font>> + for Rich<'a, Link, Theme, Renderer> +where + Link: Clone + 'a, + Theme: Catalog, + Renderer: core::text::Renderer, + Renderer::Font: 'a, +{ + fn from_iter<T: IntoIterator<Item = Span<'a, Link, Renderer::Font>>>( + spans: T, + ) -> Self { + Self::with_spans(spans.into_iter().collect::<Vec<_>>()) + } +} + +impl<'a, Link, Theme, Renderer> From<Rich<'a, Link, Theme, Renderer>> + for Element<'a, Link, Theme, Renderer> +where + Link: Clone + 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer + 'a, +{ + fn from( + text: Rich<'a, Link, Theme, Renderer>, + ) -> Element<'a, Link, Theme, Renderer> { + Element::new(text) + } +} diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs index fc2ade43..e0102656 100644 --- a/widget/src/text_editor.rs +++ b/widget/src/text_editor.rs @@ -1,4 +1,5 @@ //! Display a multi-line text input for text editing. +use crate::core::alignment; use crate::core::clipboard::{self, Clipboard}; use crate::core::event::{self, Event}; use crate::core::keyboard; @@ -8,11 +9,14 @@ use crate::core::mouse; use crate::core::renderer; use crate::core::text::editor::{Cursor, Editor as _}; use crate::core::text::highlighter::{self, Highlighter}; -use crate::core::text::{self, LineHeight}; +use crate::core::text::{self, LineHeight, Text, Wrapping}; +use crate::core::time::{Duration, Instant}; +use crate::core::widget::operation; use crate::core::widget::{self, Widget}; +use crate::core::window; use crate::core::{ - Background, Border, Color, Element, Length, Padding, Pixels, Rectangle, - Shell, Size, Theme, Vector, + Background, Border, Color, Element, Length, Padding, Pixels, Point, + Rectangle, Shell, Size, SmolStr, Theme, Vector, }; use std::cell::RefCell; @@ -36,13 +40,16 @@ pub struct TextEditor< Renderer: text::Renderer, { content: &'a Content<Renderer>, + placeholder: Option<text::Fragment<'a>>, font: Option<Renderer::Font>, text_size: Option<Pixels>, line_height: LineHeight, width: Length, height: Length, padding: Padding, + wrapping: Wrapping, class: Theme::Class<'a>, + key_binding: Option<Box<dyn Fn(KeyPress) -> Option<Binding<Message>> + 'a>>, on_edit: Option<Box<dyn Fn(Action) -> Message + 'a>>, highlighter_settings: Highlighter::Settings, highlighter_format: fn( @@ -61,13 +68,16 @@ where pub fn new(content: &'a Content<Renderer>) -> Self { Self { content, + placeholder: None, font: None, text_size: None, line_height: LineHeight::default(), width: Length::Fill, height: Length::Shrink, padding: Padding::new(5.0), + wrapping: Wrapping::default(), class: Theme::default(), + key_binding: None, on_edit: None, highlighter_settings: (), highlighter_format: |_highlight, _theme| { @@ -84,6 +94,15 @@ where Theme: Catalog, Renderer: text::Renderer, { + /// Sets the placeholder of the [`TextEditor`]. + pub fn placeholder( + mut self, + placeholder: impl text::IntoFragment<'a>, + ) -> Self { + self.placeholder = Some(placeholder.into_fragment()); + self + } + /// Sets the height of the [`TextEditor`]. pub fn height(mut self, height: impl Into<Length>) -> Self { self.height = height.into(); @@ -131,9 +150,34 @@ where self } + /// Sets the [`Wrapping`] strategy of the [`TextEditor`]. + pub fn wrapping(mut self, wrapping: Wrapping) -> Self { + self.wrapping = wrapping; + self + } + + /// Highlights the [`TextEditor`] using the given syntax and theme. + #[cfg(feature = "highlighter")] + pub fn highlight( + self, + syntax: &str, + theme: iced_highlighter::Theme, + ) -> TextEditor<'a, iced_highlighter::Highlighter, Message, Theme, Renderer> + where + Renderer: text::Renderer<Font = crate::core::Font>, + { + self.highlight_with::<iced_highlighter::Highlighter>( + iced_highlighter::Settings { + theme, + token: syntax.to_owned(), + }, + |highlight, _theme| highlight.to_format(), + ) + } + /// Highlights the [`TextEditor`] with the given [`Highlighter`] and /// a strategy to turn its highlights into some text format. - pub fn highlight<H: text::Highlighter>( + pub fn highlight_with<H: text::Highlighter>( self, settings: H::Settings, to_format: fn( @@ -143,19 +187,33 @@ where ) -> TextEditor<'a, H, Message, Theme, Renderer> { TextEditor { content: self.content, + placeholder: self.placeholder, font: self.font, text_size: self.text_size, line_height: self.line_height, width: self.width, height: self.height, padding: self.padding, + wrapping: self.wrapping, class: self.class, + key_binding: self.key_binding, on_edit: self.on_edit, highlighter_settings: settings, highlighter_format: to_format, } } + /// Sets the closure to produce key bindings on key presses. + /// + /// See [`Binding`] for the list of available bindings. + pub fn key_binding( + mut self, + key_binding: impl Fn(KeyPress) -> Option<Binding<Message>> + 'a, + ) -> Self { + self.key_binding = Some(Box::new(key_binding)); + self + } + /// Sets the style of the [`TextEditor`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self @@ -322,7 +380,7 @@ where /// The state of a [`TextEditor`]. #[derive(Debug)] pub struct State<Highlighter: text::Highlighter> { - is_focused: bool, + focus: Option<Focus>, last_click: Option<mouse::Click>, drag_click: Option<mouse::click::Kind>, partial_scroll: f32, @@ -331,10 +389,55 @@ pub struct State<Highlighter: text::Highlighter> { highlighter_format_address: usize, } +#[derive(Debug, Clone, Copy)] +struct Focus { + updated_at: Instant, + now: Instant, + is_window_focused: bool, +} + +impl Focus { + const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500; + + fn now() -> Self { + let now = Instant::now(); + + Self { + updated_at: now, + now, + is_window_focused: true, + } + } + + fn is_cursor_visible(&self) -> bool { + self.is_window_focused + && ((self.now - self.updated_at).as_millis() + / Self::CURSOR_BLINK_INTERVAL_MILLIS) + % 2 + == 0 + } +} + impl<Highlighter: text::Highlighter> State<Highlighter> { /// Returns whether the [`TextEditor`] is currently focused or not. pub fn is_focused(&self) -> bool { - self.is_focused + self.focus.is_some() + } +} + +impl<Highlighter: text::Highlighter> operation::Focusable + for State<Highlighter> +{ + fn is_focused(&self) -> bool { + self.focus.is_some() + } + + fn focus(&mut self) { + self.focus = Some(Focus::now()); + } + + fn unfocus(&mut self) { + self.focus = None; } } @@ -351,7 +454,7 @@ where fn state(&self) -> widget::tree::State { widget::tree::State::new(State { - is_focused: false, + focus: None, last_click: None, drag_click: None, partial_scroll: 0.0, @@ -402,6 +505,7 @@ where self.font.unwrap_or_else(|| renderer.default_font()), self.text_size.unwrap_or_else(|| renderer.default_size()), self.line_height, + self.wrapping, state.highlighter.borrow_mut().deref_mut(), ); @@ -439,12 +543,48 @@ where let state = tree.state.downcast_mut::<State<Highlighter>>(); + match event { + Event::Window(window::Event::Unfocused) => { + if let Some(focus) = &mut state.focus { + focus.is_window_focused = false; + } + } + Event::Window(window::Event::Focused) => { + if let Some(focus) = &mut state.focus { + focus.is_window_focused = true; + focus.updated_at = Instant::now(); + + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } + Event::Window(window::Event::RedrawRequested(now)) => { + if let Some(focus) = &mut state.focus { + if focus.is_window_focused { + focus.now = now; + + let millis_until_redraw = + Focus::CURSOR_BLINK_INTERVAL_MILLIS + - (now - focus.updated_at).as_millis() + % Focus::CURSOR_BLINK_INTERVAL_MILLIS; + + shell.request_redraw(window::RedrawRequest::At( + now + Duration::from_millis( + millis_until_redraw as u64, + ), + )); + } + } + } + _ => {} + } + let Some(update) = Update::from_event( event, state, layout.bounds(), self.padding, cursor, + self.key_binding.as_deref(), ) else { return event::Status::Ignored; }; @@ -459,12 +599,18 @@ where mouse::click::Kind::Triple => Action::SelectLine, }; - state.is_focused = true; + state.focus = Some(Focus::now()); state.last_click = Some(click); state.drag_click = Some(click.kind()); shell.publish(on_edit(action)); } + Update::Drag(position) => { + shell.publish(on_edit(Action::Drag(position))); + } + Update::Release => { + state.drag_click = None; + } Update::Scroll(lines) => { let bounds = self.content.0.borrow().editor.bounds(); @@ -479,34 +625,105 @@ where lines: lines as i32, })); } - Update::Unfocus => { - state.is_focused = false; - state.drag_click = None; - } - Update::Release => { - state.drag_click = None; - } - Update::Action(action) => { - shell.publish(on_edit(action)); - } - Update::Copy => { - if let Some(selection) = self.content.selection() { - clipboard.write(clipboard::Kind::Standard, selection); - } - } - Update::Cut => { - if let Some(selection) = self.content.selection() { - clipboard.write(clipboard::Kind::Standard, selection); - shell.publish(on_edit(Action::Edit(Edit::Delete))); + Update::Binding(binding) => { + fn apply_binding< + H: text::Highlighter, + R: text::Renderer, + Message, + >( + binding: Binding<Message>, + content: &Content<R>, + state: &mut State<H>, + on_edit: &dyn Fn(Action) -> Message, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + let mut publish = |action| shell.publish(on_edit(action)); + + match binding { + Binding::Unfocus => { + state.focus = None; + state.drag_click = None; + } + Binding::Copy => { + if let Some(selection) = content.selection() { + clipboard.write( + clipboard::Kind::Standard, + selection, + ); + } + } + Binding::Cut => { + if let Some(selection) = content.selection() { + clipboard.write( + clipboard::Kind::Standard, + selection, + ); + + publish(Action::Edit(Edit::Delete)); + } + } + Binding::Paste => { + if let Some(contents) = + clipboard.read(clipboard::Kind::Standard) + { + publish(Action::Edit(Edit::Paste(Arc::new( + contents, + )))); + } + } + Binding::Move(motion) => { + publish(Action::Move(motion)); + } + Binding::Select(motion) => { + publish(Action::Select(motion)); + } + Binding::SelectWord => { + publish(Action::SelectWord); + } + Binding::SelectLine => { + publish(Action::SelectLine); + } + Binding::SelectAll => { + publish(Action::SelectAll); + } + Binding::Insert(c) => { + publish(Action::Edit(Edit::Insert(c))); + } + Binding::Enter => { + publish(Action::Edit(Edit::Enter)); + } + Binding::Backspace => { + publish(Action::Edit(Edit::Backspace)); + } + Binding::Delete => { + publish(Action::Edit(Edit::Delete)); + } + Binding::Sequence(sequence) => { + for binding in sequence { + apply_binding( + binding, content, state, on_edit, + clipboard, shell, + ); + } + } + Binding::Custom(message) => { + shell.publish(message); + } + } } - } - Update::Paste => { - if let Some(contents) = - clipboard.read(clipboard::Kind::Standard) - { - shell.publish(on_edit(Action::Edit(Edit::Paste( - Arc::new(contents), - )))); + + apply_binding( + binding, + self.content, + state, + on_edit, + clipboard, + shell, + ); + + if let Some(focus) = &mut state.focus { + focus.updated_at = Instant::now(); } } } @@ -522,15 +739,17 @@ where defaults: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, - viewport: &Rectangle, + _viewport: &Rectangle, ) { let bounds = layout.bounds(); let mut internal = self.content.0.borrow_mut(); let state = tree.state.downcast_ref::<State<Highlighter>>(); + let font = self.font.unwrap_or_else(|| renderer.default_font()); + internal.editor.highlight( - self.font.unwrap_or_else(|| renderer.default_font()), + font, state.highlighter.borrow_mut().deref_mut(), |highlight| (self.highlighter_format)(highlight, theme), ); @@ -540,7 +759,7 @@ where let status = if is_disabled { Status::Disabled - } else if state.is_focused { + } else if state.focus.is_some() { Status::Focused } else if is_mouse_over { Status::Hovered @@ -559,22 +778,43 @@ where style.background, ); - renderer.fill_editor( - &internal.editor, - bounds.position() - + Vector::new(self.padding.left, self.padding.top), - defaults.text_color, - *viewport, - ); + let text_bounds = bounds.shrink(self.padding); + + if internal.editor.is_empty() { + if let Some(placeholder) = self.placeholder.clone() { + renderer.fill_text( + Text { + content: placeholder.into_owned(), + bounds: text_bounds.size(), + size: self + .text_size + .unwrap_or_else(|| renderer.default_size()), + line_height: self.line_height, + font, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: self.wrapping, + }, + text_bounds.position(), + style.placeholder, + text_bounds, + ); + } + } else { + renderer.fill_editor( + &internal.editor, + text_bounds.position(), + defaults.text_color, + text_bounds, + ); + } - let translation = Vector::new( - bounds.x + self.padding.left, - bounds.y + self.padding.top, - ); + let translation = text_bounds.position() - Point::ORIGIN; - if state.is_focused { + if let Some(focus) = state.focus.as_ref() { match internal.editor.cursor() { - Cursor::Caret(position) => { + Cursor::Caret(position) if focus.is_cursor_visible() => { let cursor = Rectangle::new( position + translation, @@ -588,15 +828,12 @@ where ), ); - if let Some(clipped_cursor) = bounds.intersection(&cursor) { + if let Some(clipped_cursor) = + text_bounds.intersection(&cursor) + { renderer.fill_quad( renderer::Quad { - bounds: Rectangle { - x: clipped_cursor.x.floor(), - y: clipped_cursor.y, - width: clipped_cursor.width, - height: clipped_cursor.height, - }, + bounds: clipped_cursor, ..renderer::Quad::default() }, style.value, @@ -605,7 +842,7 @@ where } Cursor::Selection(ranges) => { for range in ranges.into_iter().filter_map(|range| { - bounds.intersection(&(range + translation)) + text_bounds.intersection(&(range + translation)) }) { renderer.fill_quad( renderer::Quad { @@ -616,6 +853,7 @@ where ); } } + Cursor::Caret(_) => {} } } } @@ -640,6 +878,18 @@ where mouse::Interaction::default() } } + + fn operate( + &self, + tree: &mut widget::Tree, + _layout: Layout<'_>, + _renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + let state = tree.state.downcast_mut::<State<Highlighter>>(); + + operation.focusable(state, None); + } } impl<'a, Highlighter, Message, Theme, Renderer> @@ -658,27 +908,144 @@ where } } -enum Update { - Click(mouse::Click), - Scroll(f32), +/// A binding to an action in the [`TextEditor`]. +#[derive(Debug, Clone, PartialEq)] +pub enum Binding<Message> { + /// Unfocus the [`TextEditor`]. Unfocus, - Release, - Action(Action), + /// Copy the selection of the [`TextEditor`]. Copy, + /// Cut the selection of the [`TextEditor`]. Cut, + /// Paste the clipboard contents in the [`TextEditor`]. Paste, + /// Apply a [`Motion`]. + Move(Motion), + /// Select text with a given [`Motion`]. + Select(Motion), + /// Select the word at the current cursor. + SelectWord, + /// Select the line at the current cursor. + SelectLine, + /// Select the entire buffer. + SelectAll, + /// Insert the given character. + Insert(char), + /// Break the current line. + Enter, + /// Delete the previous character. + Backspace, + /// Delete the next character. + Delete, + /// A sequence of bindings to execute. + Sequence(Vec<Self>), + /// Produce the given message. + Custom(Message), +} + +/// A key press. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyPress { + /// The key pressed. + pub key: keyboard::Key, + /// The state of the keyboard modifiers. + pub modifiers: keyboard::Modifiers, + /// The text produced by the key press. + pub text: Option<SmolStr>, + /// The current [`Status`] of the [`TextEditor`]. + pub status: Status, +} + +impl<Message> Binding<Message> { + /// Returns the default [`Binding`] for the given key press. + pub fn from_key_press(event: KeyPress) -> Option<Self> { + let KeyPress { + key, + modifiers, + text, + status, + } = event; + + if status != Status::Focused { + return None; + } + + match key.as_ref() { + keyboard::Key::Named(key::Named::Enter) => Some(Self::Enter), + keyboard::Key::Named(key::Named::Backspace) => { + Some(Self::Backspace) + } + keyboard::Key::Named(key::Named::Delete) => Some(Self::Delete), + keyboard::Key::Named(key::Named::Escape) => Some(Self::Unfocus), + keyboard::Key::Character("c") if modifiers.command() => { + Some(Self::Copy) + } + keyboard::Key::Character("x") if modifiers.command() => { + Some(Self::Cut) + } + keyboard::Key::Character("v") + if modifiers.command() && !modifiers.alt() => + { + Some(Self::Paste) + } + keyboard::Key::Character("a") if modifiers.command() => { + Some(Self::SelectAll) + } + _ => { + if let Some(text) = text { + let c = text.chars().find(|c| !c.is_control())?; + + Some(Self::Insert(c)) + } else if let keyboard::Key::Named(named_key) = key.as_ref() { + let motion = motion(named_key)?; + + let motion = if modifiers.macos_command() { + match motion { + Motion::Left => Motion::Home, + Motion::Right => Motion::End, + _ => motion, + } + } else { + motion + }; + + let motion = if modifiers.jump() { + motion.widen() + } else { + motion + }; + + Some(if modifiers.shift() { + Self::Select(motion) + } else { + Self::Move(motion) + }) + } else { + None + } + } + } + } +} + +enum Update<Message> { + Click(mouse::Click), + Drag(Point), + Release, + Scroll(f32), + Binding(Binding<Message>), } -impl Update { +impl<Message> Update<Message> { fn from_event<H: Highlighter>( event: Event, state: &State<H>, bounds: Rectangle, padding: Padding, cursor: mouse::Cursor, + key_binding: Option<&dyn Fn(KeyPress) -> Option<Binding<Message>>>, ) -> Option<Self> { - let action = |action| Some(Update::Action(action)); - let edit = |edit| action(Action::Edit(edit)); + let binding = |binding| Some(Update::Binding(binding)); match event { Event::Mouse(event) => match event { @@ -689,12 +1056,13 @@ impl Update { let click = mouse::Click::new( cursor_position, + mouse::Button::Left, state.last_click, ); Some(Update::Click(click)) - } else if state.is_focused { - Some(Update::Unfocus) + } else if state.focus.is_some() { + binding(Binding::Unfocus) } else { None } @@ -707,7 +1075,7 @@ impl Update { let cursor_position = cursor.position_in(bounds)? - Vector::new(padding.top, padding.left); - action(Action::Drag(cursor_position)) + Some(Update::Drag(cursor_position)) } _ => None, }, @@ -727,81 +1095,32 @@ impl Update { } _ => None, }, - Event::Keyboard(event) => match event { - keyboard::Event::KeyPressed { + Event::Keyboard(keyboard::Event::KeyPressed { + key, + modifiers, + text, + .. + }) => { + let status = if state.focus.is_some() { + Status::Focused + } else { + Status::Active + }; + + let key_press = KeyPress { key, modifiers, text, - .. - } if state.is_focused => { - match key.as_ref() { - keyboard::Key::Named(key::Named::Enter) => { - return edit(Edit::Enter); - } - keyboard::Key::Named(key::Named::Backspace) => { - return edit(Edit::Backspace); - } - keyboard::Key::Named(key::Named::Delete) => { - return edit(Edit::Delete); - } - keyboard::Key::Named(key::Named::Escape) => { - return Some(Self::Unfocus); - } - keyboard::Key::Character("c") - if modifiers.command() => - { - return Some(Self::Copy); - } - keyboard::Key::Character("x") - if modifiers.command() => - { - return Some(Self::Cut); - } - keyboard::Key::Character("v") - if modifiers.command() && !modifiers.alt() => - { - return Some(Self::Paste); - } - _ => {} - } - - if let Some(text) = text { - if let Some(c) = text.chars().find(|c| !c.is_control()) - { - return edit(Edit::Insert(c)); - } - } - - if let keyboard::Key::Named(named_key) = key.as_ref() { - if let Some(motion) = motion(named_key) { - let motion = if modifiers.macos_command() { - match motion { - Motion::Left => Motion::Home, - Motion::Right => Motion::End, - _ => motion, - } - } else { - motion - }; - - let motion = if modifiers.jump() { - motion.widen() - } else { - motion - }; - - return action(if modifiers.shift() { - Action::Select(motion) - } else { - Action::Move(motion) - }); - } - } + status, + }; - None + if let Some(key_binding) = key_binding { + key_binding(key_press) + } else { + Binding::from_key_press(key_press) } - _ => None, - }, + .map(Self::Binding) + } _ => None, } } diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 4e89236b..d5ede524 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -19,7 +19,8 @@ use crate::core::keyboard::key; use crate::core::layout; use crate::core::mouse::{self, click}; use crate::core::renderer; -use crate::core::text::{self, Paragraph as _, Text}; +use crate::core::text::paragraph::{self, Paragraph as _}; +use crate::core::text::{self, Text}; use crate::core::time::{Duration, Instant}; use crate::core::touch; use crate::core::widget; @@ -30,7 +31,8 @@ use crate::core::{ Background, Border, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size, Theme, Vector, Widget, }; -use crate::runtime::{Action, Task}; +use crate::runtime::task::{self, Task}; +use crate::runtime::Action; /// A field that can be filled with text. /// @@ -72,6 +74,7 @@ pub struct TextInput< padding: Padding, size: Option<Pixels>, line_height: text::LineHeight, + alignment: alignment::Horizontal, on_input: Option<Box<dyn Fn(String) -> Message + 'a>>, on_paste: Option<Box<dyn Fn(String) -> Message + 'a>>, on_submit: Option<Message>, @@ -101,6 +104,7 @@ where padding: DEFAULT_PADDING, size: None, line_height: text::LineHeight::default(), + alignment: alignment::Horizontal::Left, on_input: None, on_paste: None, on_submit: None, @@ -125,11 +129,23 @@ where /// the [`TextInput`]. /// /// If this method is not called, the [`TextInput`] will be disabled. - pub fn on_input<F>(mut self, callback: F) -> Self - where - F: 'a + Fn(String) -> Message, - { - self.on_input = Some(Box::new(callback)); + pub fn on_input( + mut self, + on_input: impl Fn(String) -> Message + 'a, + ) -> Self { + self.on_input = Some(Box::new(on_input)); + self + } + + /// Sets the message that should be produced when some text is typed into + /// the [`TextInput`], if `Some`. + /// + /// If `None`, the [`TextInput`] will be disabled. + pub fn on_input_maybe( + mut self, + on_input: Option<impl Fn(String) -> Message + 'a>, + ) -> Self { + self.on_input = on_input.map(|f| Box::new(f) as _); self } @@ -140,6 +156,13 @@ where self } + /// Sets the message that should be produced when the [`TextInput`] is + /// focused and the enter key is pressed, if `Some`. + pub fn on_submit_maybe(mut self, on_submit: Option<Message>) -> Self { + self.on_submit = on_submit; + self + } + /// Sets the message that should be produced when some text is pasted into /// the [`TextInput`]. pub fn on_paste( @@ -150,6 +173,16 @@ where self } + /// Sets the message that should be produced when some text is pasted into + /// the [`TextInput`], if `Some`. + pub fn on_paste_maybe( + mut self, + on_paste: Option<impl Fn(String) -> Message + 'a>, + ) -> Self { + self.on_paste = on_paste.map(|f| Box::new(f) as _); + self + } + /// Sets the [`Font`] of the [`TextInput`]. /// /// [`Font`]: text::Renderer::Font @@ -191,6 +224,15 @@ where self } + /// Sets the horizontal alignment of the [`TextInput`]. + pub fn align_x( + mut self, + alignment: impl Into<alignment::Horizontal>, + ) -> Self { + self.alignment = alignment.into(); + self + } + /// Sets the style of the [`TextInput`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self @@ -238,6 +280,7 @@ where horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), }; state.placeholder.update(placeholder_text); @@ -262,6 +305,7 @@ where horizontal_alignment: alignment::Horizontal::Center, vertical_alignment: alignment::Vertical::Center, shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), }; state.icon.update(icon_text); @@ -359,7 +403,7 @@ where let icon_layout = children_layout.next().unwrap(); renderer.fill_paragraph( - &state.icon, + state.icon.raw(), icon_layout.bounds().center(), style.icon, *viewport, @@ -377,16 +421,16 @@ where cursor::State::Index(position) => { let (text_value_width, offset) = measure_cursor_and_scroll_offset( - &state.value, + state.value.raw(), text_bounds, position, ); - let is_cursor_visible = ((focus.now - focus.updated_at) - .as_millis() - / CURSOR_BLINK_INTERVAL_MILLIS) - % 2 - == 0; + let is_cursor_visible = !is_disabled + && ((focus.now - focus.updated_at).as_millis() + / CURSOR_BLINK_INTERVAL_MILLIS) + % 2 + == 0; let cursor = if is_cursor_visible { Some(( @@ -414,14 +458,14 @@ where let (left_position, left_offset) = measure_cursor_and_scroll_offset( - &state.value, + state.value.raw(), text_bounds, left, ); let (right_position, right_offset) = measure_cursor_and_scroll_offset( - &state.value, + state.value.raw(), text_bounds, right, ); @@ -455,9 +499,21 @@ where }; let draw = |renderer: &mut Renderer, viewport| { + let paragraph = if text.is_empty() { + state.placeholder.raw() + } else { + state.value.raw() + }; + + let alignment_offset = alignment_offset( + text_bounds.width, + paragraph.min_width(), + self.alignment, + ); + if let Some((cursor, color)) = cursor { renderer.with_translation( - Vector::new(-offset, 0.0), + Vector::new(alignment_offset - offset, 0.0), |renderer| { renderer.fill_quad(cursor, color); }, @@ -467,13 +523,9 @@ where } renderer.fill_paragraph( - if text.is_empty() { - &state.placeholder - } else { - &state.value - }, + paragraph, Point::new(text_bounds.x, text_bounds.center_y()) - - Vector::new(offset, 0.0), + + Vector::new(alignment_offset - offset, 0.0), if text.is_empty() { style.placeholder } else { @@ -510,12 +562,9 @@ where fn diff(&self, tree: &mut Tree) { let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>(); - // Unfocus text input if it becomes disabled + // Stop pasting if input becomes disabled if self.on_input.is_none() { - state.last_click = None; - state.is_focused = None; state.is_pasting = None; - state.is_dragging = false; } } @@ -540,7 +589,7 @@ where tree: &mut Tree, _layout: Layout<'_>, _renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>(); @@ -576,11 +625,7 @@ where | Event::Touch(touch::Event::FingerPressed { .. }) => { let state = state::<Renderer>(tree); - let click_position = if self.on_input.is_some() { - cursor.position_over(layout.bounds()) - } else { - None - }; + let click_position = cursor.position_over(layout.bounds()); state.is_focused = if click_position.is_some() { state.is_focused.or_else(|| { @@ -598,10 +643,24 @@ where if let Some(cursor_position) = click_position { let text_layout = layout.children().next().unwrap(); - let target = cursor_position.x - text_layout.bounds().x; - let click = - mouse::Click::new(cursor_position, state.last_click); + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + self.alignment, + ); + + cursor_position.x - text_bounds.x - alignment_offset + }; + + let click = mouse::Click::new( + cursor_position, + mouse::Button::Left, + state.last_click, + ); match click.kind() { click::Kind::Single => { @@ -675,7 +734,18 @@ where if state.is_dragging { let text_layout = layout.children().next().unwrap(); - let target = position.x - text_layout.bounds().x; + + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + self.alignment, + ); + + position.x - text_bounds.x - alignment_offset + }; let value = if self.is_secure { self.value.secure() @@ -704,10 +774,6 @@ where let state = state::<Renderer>(tree); if let Some(focus) = &mut state.is_focused { - let Some(on_input) = &self.on_input else { - return event::Status::Ignored; - }; - let modifiers = state.keyboard_modifiers; focus.updated_at = Instant::now(); @@ -731,6 +797,10 @@ where if state.keyboard_modifiers.command() && !self.is_secure => { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + if let Some((start, end)) = state.cursor.selection(&self.value) { @@ -755,6 +825,10 @@ where if state.keyboard_modifiers.command() && !state.keyboard_modifiers.alt() => { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + let content = match state.is_pasting.take() { Some(content) => content, None => { @@ -798,6 +872,10 @@ where } if let Some(text) = text { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + state.is_pasting = None; if let Some(c) = @@ -826,6 +904,10 @@ where } } keyboard::Key::Named(key::Named::Backspace) => { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + if modifiers.jump() && state.cursor.selection(&self.value).is_none() { @@ -850,6 +932,10 @@ where update_cache(state, &self.value); } keyboard::Key::Named(key::Named::Delete) => { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + if modifiers.jump() && state.cursor.selection(&self.value).is_none() { @@ -1068,7 +1154,7 @@ where ) -> mouse::Interaction { if cursor.is_over(layout.bounds()) { if self.on_input.is_none() { - mouse::Interaction::NotAllowed + mouse::Interaction::Idle } else { mouse::Interaction::Text } @@ -1142,13 +1228,13 @@ impl From<Id> for widget::Id { /// Produces a [`Task`] that focuses the [`TextInput`] with the given [`Id`]. pub fn focus<T>(id: Id) -> Task<T> { - Task::effect(Action::widget(operation::focusable::focus(id.0))) + task::effect(Action::widget(operation::focusable::focus(id.0))) } /// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the /// end. pub fn move_cursor_to_end<T>(id: Id) -> Task<T> { - Task::effect(Action::widget(operation::text_input::move_cursor_to_end( + task::effect(Action::widget(operation::text_input::move_cursor_to_end( id.0, ))) } @@ -1156,7 +1242,7 @@ pub fn move_cursor_to_end<T>(id: Id) -> Task<T> { /// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the /// front. pub fn move_cursor_to_front<T>(id: Id) -> Task<T> { - Task::effect(Action::widget(operation::text_input::move_cursor_to_front( + task::effect(Action::widget(operation::text_input::move_cursor_to_front( id.0, ))) } @@ -1164,22 +1250,22 @@ pub fn move_cursor_to_front<T>(id: Id) -> Task<T> { /// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the /// provided position. pub fn move_cursor_to<T>(id: Id, position: usize) -> Task<T> { - Task::effect(Action::widget(operation::text_input::move_cursor_to( + task::effect(Action::widget(operation::text_input::move_cursor_to( id.0, position, ))) } /// Produces a [`Task`] that selects all the content of the [`TextInput`] with the given [`Id`]. pub fn select_all<T>(id: Id) -> Task<T> { - Task::effect(Action::widget(operation::text_input::select_all(id.0))) + task::effect(Action::widget(operation::text_input::select_all(id.0))) } /// The state of a [`TextInput`]. #[derive(Debug, Default, Clone)] pub struct State<P: text::Paragraph> { - value: P, - placeholder: P, - icon: P, + value: paragraph::Plain<P>, + placeholder: paragraph::Plain<P>, + icon: paragraph::Plain<P>, is_focused: Option<Focus>, is_dragging: bool, is_pasting: Option<Value>, @@ -1208,21 +1294,6 @@ impl<P: text::Paragraph> State<P> { Self::default() } - /// Creates a new [`State`], representing a focused [`TextInput`]. - pub fn focused() -> Self { - Self { - value: P::default(), - placeholder: P::default(), - icon: P::default(), - is_focused: None, - is_dragging: false, - is_pasting: None, - last_click: None, - cursor: Cursor::default(), - keyboard_modifiers: keyboard::Modifiers::default(), - } - } - /// Returns whether the [`TextInput`] is currently focused or not. pub fn is_focused(&self) -> bool { self.is_focused.is_some() @@ -1318,7 +1389,7 @@ fn offset<P: text::Paragraph>( }; let (_, offset) = measure_cursor_and_scroll_offset( - &state.value, + state.value.raw(), text_bounds, focus_position, ); @@ -1356,6 +1427,7 @@ fn find_cursor_position<P: text::Paragraph>( let char_offset = state .value + .raw() .hit_test(Point::new(x + offset, text_bounds.height / 2.0)) .map(text::Hit::cursor)?; @@ -1385,7 +1457,7 @@ fn replace_paragraph<Renderer>( let mut children_layout = layout.children(); let text_bounds = children_layout.next().unwrap().bounds(); - state.value = Renderer::Paragraph::with_text(Text { + state.value = paragraph::Plain::new(Text { font, line_height, content: &value.to_string(), @@ -1394,6 +1466,7 @@ fn replace_paragraph<Renderer>( horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Top, shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), }); } @@ -1498,3 +1571,21 @@ pub fn default(theme: &Theme, status: Status) -> Style { }, } } + +fn alignment_offset( + text_bounds_width: f32, + text_min_width: f32, + alignment: alignment::Horizontal, +) -> f32 { + if text_min_width > text_bounds_width { + 0.0 + } else { + match alignment { + alignment::Horizontal::Left => 0.0, + alignment::Horizontal::Center => { + (text_bounds_width - text_min_width) / 2.0 + } + alignment::Horizontal::Right => text_bounds_width - text_min_width, + } + } +} diff --git a/widget/src/themer.rs b/widget/src/themer.rs index 9eb47d84..499a9fe8 100644 --- a/widget/src/themer.rs +++ b/widget/src/themer.rs @@ -104,7 +104,7 @@ where tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { self.content .as_widget() @@ -236,7 +236,7 @@ where &mut self, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation<()>, + operation: &mut dyn Operation, ) { self.content.operate(layout, renderer, operation); } diff --git a/widget/src/toggler.rs b/widget/src/toggler.rs index ca6e37b0..1c425dc1 100644 --- a/widget/src/toggler.rs +++ b/widget/src/toggler.rs @@ -26,7 +26,9 @@ use crate::core::{ /// /// let is_toggled = true; /// -/// Toggler::new(String::from("Toggle me!"), is_toggled, |b| Message::TogglerToggled(b)); +/// Toggler::new(is_toggled) +/// .label("Toggle me!") +/// .on_toggle(Message::TogglerToggled); /// ``` #[allow(missing_debug_implementations)] pub struct Toggler< @@ -39,14 +41,15 @@ pub struct Toggler< Renderer: text::Renderer, { is_toggled: bool, - on_toggle: Box<dyn Fn(bool) -> Message + 'a>, - label: Option<String>, + on_toggle: Option<Box<dyn Fn(bool) -> Message + 'a>>, + label: Option<text::Fragment<'a>>, width: Length, size: f32, text_size: Option<Pixels>, text_line_height: text::LineHeight, text_alignment: alignment::Horizontal, text_shaping: text::Shaping, + text_wrapping: text::Wrapping, spacing: f32, font: Option<Renderer::Font>, class: Theme::Class<'a>, @@ -68,30 +71,54 @@ where /// * a function that will be called when the [`Toggler`] is toggled. It /// will receive the new state of the [`Toggler`] and must produce a /// `Message`. - pub fn new<F>( - label: impl Into<Option<String>>, - is_toggled: bool, - f: F, - ) -> Self - where - F: 'a + Fn(bool) -> Message, - { + pub fn new(is_toggled: bool) -> Self { Toggler { is_toggled, - on_toggle: Box::new(f), - label: label.into(), - width: Length::Fill, + on_toggle: None, + label: None, + width: Length::Shrink, size: Self::DEFAULT_SIZE, text_size: None, text_line_height: text::LineHeight::default(), text_alignment: alignment::Horizontal::Left, - text_shaping: text::Shaping::Basic, + text_shaping: text::Shaping::default(), + text_wrapping: text::Wrapping::default(), spacing: Self::DEFAULT_SIZE / 2.0, font: None, class: Theme::default(), } } + /// Sets the label of the [`Toggler`]. + pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self { + self.label = Some(label.into_fragment()); + self + } + + /// Sets the message that should be produced when a user toggles + /// the [`Toggler`]. + /// + /// If this method is not called, the [`Toggler`] will be disabled. + pub fn on_toggle( + mut self, + on_toggle: impl Fn(bool) -> Message + 'a, + ) -> Self { + self.on_toggle = Some(Box::new(on_toggle)); + self + } + + /// Sets the message that should be produced when a user toggles + /// the [`Toggler`], if `Some`. + /// + /// If `None`, the [`Toggler`] will be disabled. + pub fn on_toggle_maybe( + mut self, + on_toggle: Option<impl Fn(bool) -> Message + 'a>, + ) -> Self { + self.on_toggle = on_toggle.map(|on_toggle| Box::new(on_toggle) as _); + self + } + /// Sets the size of the [`Toggler`]. pub fn size(mut self, size: impl Into<Pixels>) -> Self { self.size = size.into().0; @@ -131,6 +158,12 @@ where self } + /// Sets the [`text::Wrapping`] strategy of the [`Toggler`]. + pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self { + self.text_wrapping = wrapping; + self + } + /// Sets the spacing between the [`Toggler`] and the text. pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self { self.spacing = spacing.into().0; @@ -216,6 +249,7 @@ where self.text_alignment, alignment::Vertical::Top, self.text_shaping, + self.text_wrapping, ) } else { layout::Node::new(Size::ZERO) @@ -235,13 +269,17 @@ where shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) -> event::Status { + let Some(on_toggle) = &self.on_toggle else { + return event::Status::Ignored; + }; + match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { let mouse_over = cursor.is_over(layout.bounds()); if mouse_over { - shell.publish((self.on_toggle)(!self.is_toggled)); + shell.publish(on_toggle(!self.is_toggled)); event::Status::Captured } else { @@ -261,7 +299,11 @@ where _renderer: &Renderer, ) -> mouse::Interaction { if cursor.is_over(layout.bounds()) { - mouse::Interaction::Pointer + if self.on_toggle.is_some() { + mouse::Interaction::Pointer + } else { + mouse::Interaction::NotAllowed + } } else { mouse::Interaction::default() } @@ -289,12 +331,14 @@ where if self.label.is_some() { let label_layout = children.next().unwrap(); + let state: &widget::text::State<Renderer::Paragraph> = + tree.state.downcast_ref(); crate::text::draw( renderer, style, label_layout, - tree.state.downcast_ref(), + state.0.raw(), crate::text::Style::default(), viewport, ); @@ -303,7 +347,9 @@ where let bounds = toggler_layout.bounds(); let is_mouse_over = cursor.is_over(layout.bounds()); - let status = if is_mouse_over { + let status = if self.on_toggle.is_none() { + Status::Disabled + } else if is_mouse_over { Status::Hovered { is_toggled: self.is_toggled, } @@ -392,6 +438,8 @@ pub enum Status { /// Indicates whether the [`Toggler`] is toggled. is_toggled: bool, }, + /// The [`Toggler`] is disabled. + Disabled, } /// The appearance of a toggler. @@ -452,6 +500,7 @@ pub fn default(theme: &Theme, status: Status) -> Style { palette.background.strong.color } } + Status::Disabled => palette.background.weak.color, }; let foreground = match status { @@ -472,6 +521,7 @@ pub fn default(theme: &Theme, status: Status) -> Style { palette.background.weak.color } } + Status::Disabled => palette.background.base.color, }; Style { diff --git a/widget/src/vertical_slider.rs b/widget/src/vertical_slider.rs index defb442f..03ec374c 100644 --- a/widget/src/vertical_slider.rs +++ b/widget/src/vertical_slider.rs @@ -5,6 +5,7 @@ pub use crate::slider::{ default, Catalog, Handle, HandleShape, Status, Style, StyleFn, }; +use crate::core::border::Border; use crate::core::event::{self, Event}; use crate::core::keyboard; use crate::core::keyboard::key::{self, Key}; @@ -14,8 +15,8 @@ use crate::core::renderer; use crate::core::touch; use crate::core::widget::tree::{self, Tree}; use crate::core::{ - self, Border, Clipboard, Element, Length, Pixels, Point, Rectangle, Shell, - Size, Widget, + self, Clipboard, Element, Length, Pixels, Point, Rectangle, Shell, Size, + Widget, }; /// An vertical bar and a handle that selects a single value from a range of @@ -71,8 +72,8 @@ where /// * an inclusive range of possible values /// * the current value of the [`VerticalSlider`] /// * a function that will be called when the [`VerticalSlider`] is dragged. - /// It receives the new value of the [`VerticalSlider`] and must produce a - /// `Message`. + /// It receives the new value of the [`VerticalSlider`] and must produce a + /// `Message`. pub fn new<F>(range: RangeInclusive<T>, value: T, on_change: F) -> Self where F: 'a + Fn(T) -> Message, @@ -239,7 +240,7 @@ where let steps = (percent * (end - start) / step).round(); let value = steps * step + start; - T::from_f64(value) + T::from_f64(value.min(end)) }; new_value @@ -412,10 +413,10 @@ where width: style.rail.width, height: offset + handle_width / 2.0, }, - border: Border::rounded(style.rail.border_radius), + border: style.rail.border, ..renderer::Quad::default() }, - style.rail.colors.1, + style.rail.backgrounds.1, ); renderer.fill_quad( @@ -426,10 +427,10 @@ where width: style.rail.width, height: bounds.height - offset - handle_width / 2.0, }, - border: Border::rounded(style.rail.border_radius), + border: style.rail.border, ..renderer::Quad::default() }, - style.rail.colors.0, + style.rail.backgrounds.0, ); renderer.fill_quad( @@ -447,7 +448,7 @@ where }, ..renderer::Quad::default() }, - style.handle.color, + style.handle.background, ); } |