diff --git a/crates/kas-core/src/shell/mod.rs b/crates/kas-core/src/shell/mod.rs index c57cf7bb4..eb687a9b0 100644 --- a/crates/kas-core/src/shell/mod.rs +++ b/crates/kas-core/src/shell/mod.rs @@ -53,6 +53,7 @@ enum ProxyAction { #[cfg(test)] mod test { use super::*; + use raw_window_handle as raw; use std::time::Instant; struct Draw; @@ -231,7 +232,7 @@ mod test { fn new(_: &mut Self::Shared, _: W) -> Result where - W: raw_window_handle::HasRawWindowHandle + raw_window_handle::HasRawDisplayHandle, + W: raw::HasWindowHandle + raw::HasDisplayHandle, Self: Sized, { todo!() diff --git a/crates/kas-core/src/theme/config.rs b/crates/kas-core/src/theme/config.rs index 0ffe9cc4d..269455aad 100644 --- a/crates/kas-core/src/theme/config.rs +++ b/crates/kas-core/src/theme/config.rs @@ -18,7 +18,7 @@ pub struct Config { #[cfg_attr(feature = "serde", serde(skip))] dirty: bool, - /// Standard font size, in units of points-per-Em + /// Standard font size, in units of pixels-per-Em #[cfg_attr(feature = "serde", serde(default = "defaults::font_size"))] font_size: f32, @@ -146,7 +146,9 @@ impl Default for RasterConfig { impl Config { /// Standard font size /// - /// Units: points per Em. Pixel size depends on the screen's scale factor. + /// Units: logical (unscaled) pixels per Em. + /// + /// To convert to Points, multiply by three quarters. #[inline] pub fn font_size(&self) -> f32 { self.font_size @@ -279,7 +281,7 @@ mod defaults { } pub fn font_size() -> f32 { - 10.0 + 14.0 } pub fn color_schemes() -> BTreeMap { diff --git a/crates/kas-core/src/theme/dimensions.rs b/crates/kas-core/src/theme/dimensions.rs index f6c9c32ba..52a80b4cc 100644 --- a/crates/kas-core/src/theme/dimensions.rs +++ b/crates/kas-core/src/theme/dimensions.rs @@ -110,9 +110,8 @@ pub struct Dimensions { } impl Dimensions { - pub fn new(params: &Parameters, pt_size: f32, scale: f32) -> Self { - let dpp = scale * (96.0 / 72.0); - let dpem = dpp * pt_size; + pub fn new(params: &Parameters, font_size: f32, scale: f32) -> Self { + let dpem = scale * font_size; let text_m0 = (params.m_text.0 * scale).cast_nearest(); let text_m1 = (params.m_text.1 * scale).cast_nearest(); diff --git a/crates/kas-macros/src/widget.rs b/crates/kas-macros/src/widget.rs index 2f1ca92d3..1c60649ed 100644 --- a/crates/kas-macros/src/widget.rs +++ b/crates/kas-macros/src/widget.rs @@ -464,7 +464,6 @@ pub fn widget(attr_span: Span, mut args: WidgetArgs, scope: &mut Scope) -> Resul let (fn_set_rect, fn_nav_next, fn_find_id); let mut fn_nav_next_err = None; let mut fn_draw = None; - let mut gen_layout = false; if let Some(inner) = opt_derive { required_layout_methods = quote! { @@ -693,7 +692,6 @@ pub fn widget(attr_span: Span, mut args: WidgetArgs, scope: &mut Scope) -> Resul self.rect().contains(coord).then(|| self.id()) }; if let Some((_, layout)) = args.layout.take() { - gen_layout = true; fn_nav_next = match layout.nav_next(children.iter().map(|(ident, _, _)| ident)) { Ok(toks) => Some(toks), Err((span, msg)) => { @@ -913,9 +911,8 @@ pub fn widget(attr_span: Span, mut args: WidgetArgs, scope: &mut Scope) -> Resul if !has_item("nav_next") { if let Some(method) = fn_nav_next { layout_impl.items.push(Verbatim(method)); - } else if gen_layout { + } else if let Some((span, msg)) = fn_nav_next_err { // We emit a warning here only if nav_next is not explicitly defined - let (span, msg) = fn_nav_next_err.unwrap(); emit_warning!(span, "unable to generate `fn Layout::nav_next`: {}", msg,); } } @@ -957,6 +954,12 @@ pub fn widget(attr_span: Span, mut args: WidgetArgs, scope: &mut Scope) -> Resul layout_impl.items.push(Verbatim(method)); } } else if let Some(fn_size_rules) = fn_size_rules { + if fn_nav_next.is_none() { + if let Some((span, msg)) = fn_nav_next_err { + emit_warning!(span, "unable to generate `fn Layout::nav_next`: {}", msg,); + } + } + scope.generated.push(quote! { impl #impl_generics ::kas::Layout for #impl_target { #required_layout_methods diff --git a/crates/kas-widgets/src/adapt/adapt_events.rs b/crates/kas-widgets/src/adapt/adapt_events.rs index 031400fef..a4d94d39b 100644 --- a/crates/kas-widgets/src/adapt/adapt_events.rs +++ b/crates/kas-widgets/src/adapt/adapt_events.rs @@ -5,8 +5,9 @@ //! Event adapters -use kas::event::ConfigCx; +use kas::event::{ConfigCx, EventCx}; use kas::{autoimpl, impl_scope, widget_index, Events, Widget}; +use std::fmt::Debug; impl_scope! { /// Wrapper to call a closure on update @@ -21,6 +22,7 @@ impl_scope! { pub inner: W, on_configure: Option>, on_update: Option>, + message_handlers: Vec>, } impl Self { @@ -32,12 +34,11 @@ impl_scope! { inner, on_configure: None, on_update: None, + message_handlers: vec![], } } /// Call the given closure on [`Events::configure`] - /// - /// Returns a wrapper around the input widget. #[must_use] pub fn on_configure(mut self, f: F) -> Self where @@ -48,8 +49,6 @@ impl_scope! { } /// Call the given closure on [`Events::update`] - /// - /// Returns a wrapper around the input widget. #[must_use] pub fn on_update(mut self, f: F) -> Self where @@ -58,6 +57,30 @@ impl_scope! { self.on_update = Some(Box::new(f)); self } + + /// Add a handler on message of type `M` + #[must_use] + pub fn on_message(self, handler: H) -> Self + where + M: Debug + 'static, + H: Fn(&mut EventCx, &mut W, &W::Data, M) + 'static, + { + self.on_messages(move |cx, w, data| { + if let Some(m) = cx.try_pop() { + handler(cx, w, data, m); + } + }) + } + + /// Add a generic message handler + #[must_use] + pub fn on_messages(mut self, handler: H) -> Self + where + H: Fn(&mut EventCx, &mut W, &W::Data) + 'static, + { + self.message_handlers.push(Box::new(handler)); + self + } } impl Events for Self { @@ -83,5 +106,11 @@ impl_scope! { f(cx, &mut self.inner, data); } } + + fn handle_messages(&mut self, cx: &mut EventCx, data: &W::Data) { + for handler in self.message_handlers.iter() { + handler(cx, &mut self.inner, data); + } + } } } diff --git a/crates/kas-widgets/src/adapt/adapt_widget.rs b/crates/kas-widgets/src/adapt/adapt_widget.rs index b980b07d2..de39b6a51 100644 --- a/crates/kas-widgets/src/adapt/adapt_widget.rs +++ b/crates/kas-widgets/src/adapt/adapt_widget.rs @@ -8,13 +8,14 @@ use super::{Map, MapAny, OnUpdate, Reserve, WithLabel}; use kas::cast::{Cast, CastFloat}; use kas::dir::Directional; -use kas::event::ConfigCx; +use kas::event::{ConfigCx, EventCx}; use kas::geom::Vec2; use kas::layout::{AxisInfo, SizeRules}; use kas::text::AccessString; use kas::theme::SizeCx; +#[allow(unused)] use kas::Events; use kas::Widget; -#[allow(unused)] use kas::{Events, Layout}; +use std::fmt::Debug; /// Provides `.map_any()` /// @@ -66,6 +67,29 @@ pub trait AdaptWidget: Widget + Sized { OnUpdate::new(self).on_update(f) } + /// Add a handler on message of type `M` + /// + /// Returns a wrapper around the input widget. + #[must_use] + fn on_message(self, handler: H) -> OnUpdate + where + M: Debug + 'static, + H: Fn(&mut EventCx, &mut Self, &Self::Data, M) + 'static, + { + OnUpdate::new(self).on_message(handler) + } + + /// Add a generic message handler + /// + /// Returns a wrapper around the input widget. + #[must_use] + fn on_messages(self, handler: H) -> OnUpdate + where + H: Fn(&mut EventCx, &mut Self, &Self::Data) + 'static, + { + OnUpdate::new(self).on_messages(handler) + } + /// Construct a wrapper, setting minimum size in pixels /// /// The input size is scaled by the scale factor. diff --git a/crates/kas-widgets/src/lib.rs b/crates/kas-widgets/src/lib.rs index 09866c894..4d0797a4c 100644 --- a/crates/kas-widgets/src/lib.rs +++ b/crates/kas-widgets/src/lib.rs @@ -44,8 +44,10 @@ //! - [`Filler`]: an empty widget, sometimes used to fill space //! - [`Image`]: a pixmap image //! - [`Label`], [`AccessLabel`]: are static text labels +//! - [`Text`]: a dynamic (input-data derived) text label //! - [`Mark`]: a small mark -//! - [`ScrollLabel`]: text label supporting scrolling and selection +//! - [`ScrollLabel`]: static text label supporting scrolling and selection +//! - [`ScrollText`]: dynamic text label supporting scrolling and selection //! - [`Separator`]: a visible bar to separate things //! - [`format_value`] and [`format_data`] are constructors for [`Text`], //! displaying a text label derived from input data @@ -84,6 +86,7 @@ mod radio_box; mod scroll; mod scroll_bar; mod scroll_label; +mod scroll_text; mod separator; mod slider; mod spinner; @@ -111,6 +114,7 @@ pub use radio_box::{RadioBox, RadioButton}; pub use scroll::ScrollRegion; pub use scroll_bar::{ScrollBar, ScrollBarRegion, ScrollBars, ScrollMsg}; pub use scroll_label::ScrollLabel; +pub use scroll_text::ScrollText; pub use separator::Separator; pub use slider::{Slider, SliderValue}; pub use spinner::{Spinner, SpinnerValue}; diff --git a/crates/kas-widgets/src/scroll_label.rs b/crates/kas-widgets/src/scroll_label.rs index c3dba5681..67adbcd5a 100644 --- a/crates/kas-widgets/src/scroll_label.rs +++ b/crates/kas-widgets/src/scroll_label.rs @@ -15,7 +15,7 @@ use kas::text::{SelectionHelper, Text}; use kas::theme::TextClass; impl_scope! { - /// A text label supporting scrolling and selection + /// A static text label supporting scrolling and selection /// /// Line-wrapping is enabled; default alignment is derived from the script /// (usually top-left). diff --git a/crates/kas-widgets/src/scroll_text.rs b/crates/kas-widgets/src/scroll_text.rs new file mode 100644 index 000000000..3a228cb11 --- /dev/null +++ b/crates/kas-widgets/src/scroll_text.rs @@ -0,0 +1,319 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License in the LICENSE-APACHE file or at: +// https://www.apache.org/licenses/LICENSE-2.0 + +//! Scrollable and selectable dynamic text + +use super::{ScrollBar, ScrollMsg}; +use kas::event::components::{TextInput, TextInputAction}; +use kas::event::{Command, CursorIcon, FocusSource, Scroll, ScrollDelta}; +use kas::geom::Vec2; +use kas::prelude::*; +use kas::text::format::{EditableText, FormattableText}; +use kas::text::{SelectionHelper, Text}; +use kas::theme::TextClass; + +impl_scope! { + /// A dynamic text label supporting scrolling and selection + /// + /// Line-wrapping is enabled; default alignment is derived from the script + /// (usually top-left). + #[widget{ + cursor_icon = CursorIcon::Text; + }] + pub struct ScrollText { + core: widget_core!(), + view_offset: Offset, + text: Text, + text_fn: Box T>, + text_size: Size, + selection: SelectionHelper, + has_sel_focus: bool, + input_handler: TextInput, + #[widget(&())] + bar: ScrollBar, + } + + impl Layout for Self { + fn size_rules(&mut self, sizer: SizeCx, axis: AxisInfo) -> SizeRules { + let class = TextClass::LabelScroll; + let mut rules = sizer.text_rules(&mut self.text, class, axis); + let _ = self.bar.size_rules(sizer.re(), axis); + if axis.is_vertical() { + rules.reduce_min_to(sizer.line_height(class) * 4); + } + rules + } + + fn set_rect(&mut self, cx: &mut ConfigCx, mut rect: Rect) { + self.core.rect = rect; + cx.text_set_size(&mut self.text, TextClass::LabelScroll, rect.size, None); + self.text_size = Vec2::from(self.text.bounding_box().unwrap().1).cast_ceil(); + + let max_offset = self.max_scroll_offset(); + self.view_offset = self.view_offset.min(max_offset); + + let w = cx.size_cx().scroll_bar_width().min(rect.size.0); + rect.pos.0 += rect.size.0 - w; + rect.size.0 = w; + self.bar.set_rect(cx, rect); + let _ = self.bar.set_limits(max_offset.1, rect.size.1); + self.bar.set_value(cx, self.view_offset.1); + } + + fn find_id(&mut self, coord: Coord) -> Option { + if !self.rect().contains(coord) { + return None; + } + + self.bar.find_id(coord).or_else(|| Some(self.id())) + } + + fn draw(&mut self, mut draw: DrawCx) { + let class = TextClass::LabelScroll; + let rect = Rect::new(self.rect().pos, self.text_size); + draw.with_clip_region(self.rect(), self.view_offset, |mut draw| { + if self.selection.is_empty() { + draw.text(rect, &self.text, class); + } else { + // TODO(opt): we could cache the selection rectangles here to make + // drawing more efficient (self.text.highlight_lines(range) output). + // The same applies to the edit marker below. + draw.text_selected(rect, &self.text, self.selection.range(), class); + } + }); + draw.with_pass(|mut draw| { + draw.recurse(&mut self.bar); + }); + } + } + + impl Self { + /// Construct an `ScrollText` with the given inital `text` + #[inline] + pub fn new(text_fn: impl Fn(&ConfigCx, &A) -> T + 'static) -> Self { + ScrollText { + core: Default::default(), + view_offset: Default::default(), + text: Text::new(T::default()), + text_fn: Box::new(text_fn), + text_size: Size::ZERO, + selection: SelectionHelper::new(0, 0), + has_sel_focus: false, + input_handler: Default::default(), + bar: ScrollBar::new().with_invisible(true), + } + } + + fn set_edit_pos_from_coord(&mut self, cx: &mut EventCx, coord: Coord) { + let rel_pos = (coord - self.rect().pos + self.view_offset).cast(); + if let Ok(pos) = self.text.text_index_nearest(rel_pos) { + if pos != self.selection.edit_pos() { + self.selection.set_edit_pos(pos); + self.set_view_offset_from_edit_pos(cx, pos); + self.bar.set_value(cx, self.view_offset.1); + cx.redraw(self); + } + } + } + + fn set_primary(&self, cx: &mut EventCx) { + if self.has_sel_focus && !self.selection.is_empty() && cx.has_primary() { + let range = self.selection.range(); + cx.set_primary(String::from(&self.text.as_str()[range])); + } + } + + // Pan by given delta. + fn pan_delta(&mut self, cx: &mut EventCx, mut delta: Offset) -> IsUsed { + let new_offset = (self.view_offset - delta) + .min(self.max_scroll_offset()) + .max(Offset::ZERO); + if new_offset != self.view_offset { + delta -= self.view_offset - new_offset; + self.set_offset(cx, new_offset); + } + + cx.set_scroll(if delta == Offset::ZERO { + Scroll::Scrolled + } else { + Scroll::Offset(delta) + }); + Used + } + + /// Update view_offset from edit_pos + /// + /// This method is mostly identical to its counterpart in `EditField`. + fn set_view_offset_from_edit_pos(&mut self, cx: &mut EventCx, edit_pos: usize) { + if let Some(marker) = self + .text + .text_glyph_pos(edit_pos) + .ok() + .and_then(|mut m| m.next_back()) + { + let bounds = Vec2::from(self.text.env().bounds); + let min_x = marker.pos.0 - bounds.0; + let min_y = marker.pos.1 - marker.descent - bounds.1; + let max_x = marker.pos.0; + let max_y = marker.pos.1 - marker.ascent; + let min = Offset(min_x.cast_ceil(), min_y.cast_ceil()); + let max = Offset(max_x.cast_floor(), max_y.cast_floor()); + + let max = max.min(self.max_scroll_offset()); + + let new_offset = self.view_offset.max(min).min(max); + if new_offset != self.view_offset { + self.view_offset = new_offset; + cx.set_scroll(Scroll::Scrolled); + } + } + } + + /// Set offset, updating the scroll bar + fn set_offset(&mut self, cx: &mut EventState, offset: Offset) { + self.view_offset = offset; + // unnecessary: cx.redraw(self); + self.bar.set_value(cx, offset.1); + } + } + + impl HasStr for Self { + fn get_str(&self) -> &str { + self.text.as_str() + } + } + + impl HasString for Self + where + T: EditableText, + { + fn set_string(&mut self, string: String) -> Action { + self.text.set_string(string); + let _ = self.text.try_prepare(); + Action::REDRAW + } + } + + impl Events for Self { + type Data = A; + + fn update(&mut self, cx: &mut ConfigCx, data: &A) { + let text = (self.text_fn)(cx, data); + if text.as_str() == self.text.as_str() { + // NOTE(opt): avoiding re-preparation of text is a *huge* + // optimisation. Move into kas-text? + return; + } + self.text.set_text(text); + if self.text.env().bounds.1.is_finite() { + // NOTE: bounds are initially infinite. Alignment results in + // infinite offset and thus infinite measured height. + let action = match self.text.try_prepare() { + Ok(true) => Action::RESIZE, + _ => Action::REDRAW, + }; + cx.action(self, action); + } + } + + fn handle_event(&mut self, cx: &mut EventCx, _: &Self::Data, event: Event) -> IsUsed { + match event { + Event::Command(cmd, _) => match cmd { + Command::Escape | Command::Deselect if !self.selection.is_empty() => { + self.selection.set_empty(); + cx.redraw(self); + Used + } + Command::SelectAll => { + self.selection.set_sel_pos(0); + self.selection.set_edit_pos(self.text.str_len()); + self.set_primary(cx); + cx.redraw(self); + Used + } + Command::Cut | Command::Copy => { + let range = self.selection.range(); + cx.set_clipboard((self.text.as_str()[range]).to_string()); + Used + } + // TODO: scroll by command + _ => Unused, + }, + Event::SelFocus(source) => { + self.has_sel_focus = true; + if source == FocusSource::Pointer { + self.set_primary(cx); + } + Used + } + Event::LostSelFocus => { + self.has_sel_focus = false; + self.selection.set_empty(); + cx.redraw(self); + Used + } + Event::Scroll(delta) => { + let delta2 = match delta { + ScrollDelta::LineDelta(x, y) => cx.config().scroll_distance((x, y)), + ScrollDelta::PixelDelta(coord) => coord, + }; + self.pan_delta(cx, delta2) + } + event => match self.input_handler.handle(cx, self.id(), event) { + TextInputAction::None => Used, + TextInputAction::Unused => Unused, + TextInputAction::Pan(delta) => self.pan_delta(cx, delta), + TextInputAction::Focus { coord, action } => { + if let Some(coord) = coord { + self.set_edit_pos_from_coord(cx, coord); + } + self.selection.action(&self.text, action); + + if self.has_sel_focus { + self.set_primary(cx); + } else { + cx.request_sel_focus(self.id(), FocusSource::Pointer); + } + Used + } + }, + } + } + + fn handle_messages(&mut self, cx: &mut EventCx, _: &Self::Data) { + if let Some(ScrollMsg(y)) = cx.try_pop() { + let y = y.clamp(0, self.max_scroll_offset().1); + self.view_offset.1 = y; + cx.redraw(self); + } + } + } + + impl Scrollable for Self { + fn scroll_axes(&self, size: Size) -> (bool, bool) { + let max = self.max_scroll_offset(); + (max.0 > size.0, max.1 > size.1) + } + + fn max_scroll_offset(&self) -> Offset { + let text_size = Offset::conv(self.text_size); + let self_size = Offset::conv(self.rect().size); + (text_size - self_size).max(Offset::ZERO) + } + + fn scroll_offset(&self) -> Offset { + self.view_offset + } + + fn set_scroll_offset(&mut self, cx: &mut EventCx, offset: Offset) -> Offset { + let new_offset = offset.min(self.max_scroll_offset()).max(Offset::ZERO); + if new_offset != self.view_offset { + self.set_offset(cx, new_offset); + // No widget moves so do not need to report Action::REGION_MOVED + } + new_offset + } + } +} diff --git a/examples/gallery.rs b/examples/gallery.rs index cfb2d3be1..646e4c7a8 100644 --- a/examples/gallery.rs +++ b/examples/gallery.rs @@ -302,6 +302,9 @@ Demonstration of *as-you-type* formatting from **Markdown**. #[widget{ layout = float! [ pack!(right top, Button::label_msg("↻", MsgDirection).map_any()), + // NOTE: non_navigable! is needed here to avoid requiring a + // nav_next impl on list! (not generable with non-static + // direction). TODO: find a more general solution. list!(self.dir, [self.editor, non_navigable!(self.label)]), ]; }]