Label overflow + Virtual list horizontal scrolling

This commit is contained in:
Jonathan Johnson 2024-11-08 10:12:28 -08:00
parent 1bb5495f53
commit 96a265854b
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
10 changed files with 204 additions and 65 deletions

View file

@ -60,6 +60,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ConstraintLimit::fit_measured` and `FitMeasuredSize::fit_measured` now accept - `ConstraintLimit::fit_measured` and `FitMeasuredSize::fit_measured` now accept
either a `Px` or `UPx` measurement, and does not perform scaling adjustments. either a `Px` or `UPx` measurement, and does not perform scaling adjustments.
To convert `Lp` use `into_upx()` first. To convert `Lp` use `into_upx()` first.
- `IntoReader::to_label` and `IntoReader::into_label` have been moved to their
own trait: `Displayable`. This allows more flexible acceptance of types.
### Changed ### Changed
@ -269,6 +271,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
@danbulant for helping with this change! @danbulant for helping with this change!
- `ScrollBar` is a new widget that renders a scroll bar meant to scroll through - `ScrollBar` is a new widget that renders a scroll bar meant to scroll through
a large container. a large container.
- `Label::overflow` allows customizing the behavior for a label when it cannot
be drawn on a single line.
[139]: https://github.com/khonsulabs/cushy/issues/139 [139]: https://github.com/khonsulabs/cushy/issues/139

View file

@ -1,5 +1,6 @@
use cushy::value::{Destination, Dynamic, IntoReader, Source}; use cushy::value::{Destination, Dynamic, Source};
use cushy::widget::MakeWidget; use cushy::widget::MakeWidget;
use cushy::widgets::label::Displayable;
use cushy::Run; use cushy::Run;
// begin rustme snippet: readme // begin rustme snippet: readme

View file

@ -1,6 +1,7 @@
use cushy::figures::units::Lp; use cushy::figures::units::Lp;
use cushy::value::{Dynamic, IntoReader}; use cushy::value::Dynamic;
use cushy::widget::MakeWidget; use cushy::widget::MakeWidget;
use cushy::widgets::label::Displayable;
use cushy::Run; use cushy::Run;
fn main() -> cushy::Result { fn main() -> cushy::Result {

View file

@ -1,6 +1,7 @@
use cushy::debug::DebugContext; use cushy::debug::DebugContext;
use cushy::value::{Destination, Dynamic, IntoReader}; use cushy::value::{Destination, Dynamic};
use cushy::widget::MakeWidget; use cushy::widget::MakeWidget;
use cushy::widgets::label::Displayable;
use cushy::widgets::slider::Slidable; use cushy::widgets::slider::Slidable;
use cushy::{Application, Open, PendingApp}; use cushy::{Application, Open, PendingApp};

View file

@ -1,10 +1,11 @@
use cushy::animation::{LinearInterpolate, PercentBetween}; use cushy::animation::{LinearInterpolate, PercentBetween};
use cushy::figures::units::Lp; use cushy::figures::units::Lp;
use cushy::figures::Ranged; use cushy::figures::Ranged;
use cushy::value::{Destination, Dynamic, ForEach, IntoReader, Source}; use cushy::value::{Destination, Dynamic, ForEach, Source};
use cushy::widget::MakeWidget; use cushy::widget::MakeWidget;
use cushy::widgets::checkbox::Checkable; use cushy::widgets::checkbox::Checkable;
use cushy::widgets::input::InputValue; use cushy::widgets::input::InputValue;
use cushy::widgets::label::Displayable;
use cushy::widgets::slider::Slidable; use cushy::widgets::slider::Slidable;
use cushy::Run; use cushy::Run;

View file

@ -2,6 +2,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use cushy::value::Dynamic; use cushy::value::Dynamic;
use cushy::widget::MakeWidget; use cushy::widget::MakeWidget;
use cushy::widgets::label::{Displayable, LabelOverflow};
use cushy::widgets::slider::Slidable; use cushy::widgets::slider::Slidable;
use cushy::widgets::VirtualList; use cushy::widgets::VirtualList;
use cushy::Run; use cushy::Run;
@ -14,6 +15,8 @@ fn list() -> impl MakeWidget {
.expect("System Time after 1970") .expect("System Time after 1970")
.as_secs(); .as_secs();
format!("Item {index} - {timestamp}") format!("Item {index} - {timestamp}")
.into_label()
.overflow(LabelOverflow::Clip)
}); });
let content_changed = list.content_watcher().clone(); let content_changed = list.content_watcher().clone();

View file

@ -24,7 +24,7 @@ use crate::utils::WithClone;
use crate::widget::{ use crate::widget::{
MakeWidget, MakeWidgetWithTag, OnceCallback, WidgetId, WidgetInstance, WidgetList, MakeWidget, MakeWidgetWithTag, OnceCallback, WidgetId, WidgetInstance, WidgetList,
}; };
use crate::widgets::{Label, Radio, Select, Space, Switcher}; use crate::widgets::{Radio, Select, Space, Switcher};
use crate::window::WindowHandle; use crate::window::WindowHandle;
/// A source of one or more `T` values. /// A source of one or more `T` values.
@ -2662,24 +2662,6 @@ impl<T> IntoReadOnly<T> for Owned<T> {
pub trait IntoReader<T> { pub trait IntoReader<T> {
/// Returns this value as a reader. /// Returns this value as a reader.
fn into_reader(self) -> DynamicReader<T>; fn into_reader(self) -> DynamicReader<T>;
/// Returns `self` being `Display`ed in a [`Label`] widget.
fn into_label(self) -> Label<T>
where
Self: Sized,
T: Debug + Display + Send + 'static,
{
Label::new(self.into_reader())
}
/// Returns `self` being `Display`ed in a [`Label`] widget.
fn to_label(&self) -> Label<T>
where
Self: Clone,
T: Debug + Display + Send + 'static,
{
self.clone().into_label()
}
} }
impl<T> IntoReader<T> for Dynamic<T> { impl<T> IntoReader<T> for Dynamic<T> {

View file

@ -1,7 +1,7 @@
//! A read-only text widget. //! A read-only text widget.
use std::borrow::Cow; use std::borrow::Cow;
use std::fmt::{Display, Write}; use std::fmt::{Debug, Display, Write};
use figures::units::{Px, UPx}; use figures::units::{Px, UPx};
use figures::{Point, Round, Size}; use figures::{Point, Round, Size};
@ -12,7 +12,9 @@ use super::input::CowString;
use crate::context::{GraphicsContext, LayoutContext, Trackable, WidgetContext}; use crate::context::{GraphicsContext, LayoutContext, Trackable, WidgetContext};
use crate::styles::components::TextColor; use crate::styles::components::TextColor;
use crate::styles::FontFamilyList; use crate::styles::FontFamilyList;
use crate::value::{Dynamic, Generation, IntoReadOnly, ReadOnly, Value}; use crate::value::{
Dynamic, DynamicReader, Generation, IntoDynamic, IntoReadOnly, IntoValue, ReadOnly, Value,
};
use crate::widget::{MakeWidgetWithTag, Widget, WidgetInstance, WidgetTag}; use crate::widget::{MakeWidgetWithTag, Widget, WidgetInstance, WidgetTag};
use crate::window::WindowLocal; use crate::window::WindowLocal;
use crate::ConstraintLimit; use crate::ConstraintLimit;
@ -22,29 +24,46 @@ use crate::ConstraintLimit;
pub struct Label<T> { pub struct Label<T> {
/// The contents of the label. /// The contents of the label.
pub display: ReadOnly<T>, pub display: ReadOnly<T>,
/// The behavior to use when too much text is able to be displayed on a
/// single line.
pub overflow: Value<LabelOverflow>,
displayed: String, displayed: String,
prepared_text: WindowLocal<LabelCacheKey>, prepared_text: WindowLocal<LabelCacheKey>,
} }
impl<T> Label<T> impl<T> Label<T>
where where
T: std::fmt::Debug + DynamicDisplay + Send + 'static, T: Debug + DynamicDisplay + Send + 'static,
{ {
/// Returns a new label that displays `text`. /// Returns a new label that displays `text`, wrapping if necessary to fit
/// the content in the provided space.
pub fn new(text: impl IntoReadOnly<T>) -> Self { pub fn new(text: impl IntoReadOnly<T>) -> Self {
Self { Self {
display: text.into_read_only(), display: text.into_read_only(),
overflow: Value::Constant(LabelOverflow::WordWrap),
displayed: String::new(), displayed: String::new(),
prepared_text: WindowLocal::default(), prepared_text: WindowLocal::default(),
} }
} }
/// Sets the behavior when more text than can fit on a single line is
/// displayed.
#[must_use]
pub fn overflow(mut self, overflow: impl IntoValue<LabelOverflow>) -> Self {
self.overflow = overflow.into_value();
self
}
fn prepared_text( fn prepared_text(
&mut self, &mut self,
context: &mut GraphicsContext<'_, '_, '_, '_>, context: &mut GraphicsContext<'_, '_, '_, '_>,
color: Color, color: Color,
width: Px, mut width: Px,
) -> &MeasuredText<Px> { ) -> &MeasuredText<Px> {
let overflow = self.overflow.get_tracking_invalidate(context);
if overflow == LabelOverflow::Clip {
width = Px::MAX;
}
let check_generation = self.display.generation(); let check_generation = self.display.generation();
context.apply_current_font_settings(); context.apply_current_font_settings();
let current_families = context.current_family_list(); let current_families = context.current_family_list();
@ -88,7 +107,7 @@ where
impl<T> Widget for Label<T> impl<T> Widget for Label<T>
where where
T: std::fmt::Debug + DynamicDisplay + Send + 'static, T: Debug + DynamicDisplay + Send + 'static,
{ {
fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) {
self.display.invalidate_when_changed(context); self.display.invalidate_when_changed(context);
@ -158,6 +177,18 @@ impl MakeWidgetWithTag for &'_ String {
} }
} }
/// The overflow behavior for a [`Label`].
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[non_exhaustive]
pub enum LabelOverflow {
/// Any text that cannot be drawn on a single line will be clipped to the
/// bounds of the label.
Clip,
/// Wraps text at the boundaries between words and whitespace while
/// attaching punctuation to the non-wrapped word when possible.
WordWrap,
}
#[derive(Debug)] #[derive(Debug)]
struct LabelCacheKey { struct LabelCacheKey {
text: MeasuredText<Px>, text: MeasuredText<Px>,
@ -209,3 +240,65 @@ impl Display for DynamicDisplayer<'_, '_> {
self.0.fmt(self.1, f) self.0.fmt(self.1, f)
} }
} }
/// A type that can be displayed as a [`Label`].
pub trait Displayable<T>
where
T: Debug + Display + Send + 'static,
{
/// Returns this value as a displayable reader.
fn into_displayable(self) -> DynamicReader<T>;
/// Returns `self` being `Display`ed in a [`Label`] widget.
fn into_label(self) -> Label<T>
where
Self: Sized,
T: Debug + Display + Send + 'static,
{
Label::new(self.into_displayable())
}
/// Returns `self` being `Display`ed in a [`Label`] widget.
fn to_label(&self) -> Label<T>
where
Self: Clone,
{
self.clone().into_label()
}
}
impl<T> Displayable<T> for T
where
T: Debug + Display + Send + 'static,
{
fn into_displayable(self) -> DynamicReader<T> {
Dynamic::new(self).into_reader()
}
}
impl<T> Displayable<T> for Dynamic<T>
where
T: Debug + Display + Send + 'static,
{
fn into_displayable(self) -> DynamicReader<T> {
self.into_reader()
}
}
impl<T> Displayable<T> for DynamicReader<T>
where
T: Debug + Display + Send + 'static,
{
fn into_displayable(self) -> DynamicReader<T> {
self
}
}
impl<T> Displayable<T> for Value<T>
where
T: Debug + Display + Send + 'static,
{
fn into_displayable(self) -> DynamicReader<T> {
self.into_dynamic().into_reader()
}
}

View file

@ -715,7 +715,7 @@ impl Widget for ScrollBar {
) )
} else { } else {
Rect::new( Rect::new(
Point::new(UPx::ZERO, control_size.height - self.bar_width), Point::new(self.info.offset, control_size.height - self.bar_width),
Size::new(self.info.size, self.bar_width), Size::new(self.info.size, self.bar_width),
) )
}; };

View file

@ -4,7 +4,7 @@ use std::ops::Range;
use cushy::context::LayoutContext; use cushy::context::LayoutContext;
use cushy::ConstraintLimit; use cushy::ConstraintLimit;
use figures::{IntoUnsigned, UnscaledUnit}; use figures::IntoUnsigned;
use super::scroll::OwnedWidget; use super::scroll::OwnedWidget;
use crate::context::{AsEventContext, EventContext, Trackable}; use crate::context::{AsEventContext, EventContext, Trackable};
@ -12,7 +12,9 @@ use crate::figures::units::{Px, UPx};
use crate::figures::{IntoSigned, Point, Rect, Round, Size, Zero}; use crate::figures::{IntoSigned, Point, Rect, Round, Size, Zero};
use crate::kludgine::app::winit::event::{MouseScrollDelta, TouchPhase}; use crate::kludgine::app::winit::event::{MouseScrollDelta, TouchPhase};
use crate::kludgine::app::winit::window::CursorIcon; use crate::kludgine::app::winit::window::CursorIcon;
use crate::value::{Destination, Dynamic, DynamicReader, IntoDynamic, IntoValue, Source, Watcher}; use crate::value::{
Destination, Dynamic, DynamicReader, IntoDynamic, IntoValue, MapEachCloned, Source, Watcher,
};
use crate::widget::{ use crate::widget::{
Callback, EventHandling, MakeWidget, MountedWidget, Widget, WidgetInstance, HANDLED, IGNORED, Callback, EventHandling, MakeWidget, MountedWidget, Widget, WidgetInstance, HANDLED, IGNORED,
}; };
@ -49,6 +51,7 @@ struct VirtualListItem {
pub struct VirtualList { pub struct VirtualList {
make_row: RowMaker, make_row: RowMaker,
vertical_scroll: OwnedWidget<ScrollBar>, vertical_scroll: OwnedWidget<ScrollBar>,
horizontal_scroll: OwnedWidget<ScrollBar>,
items: VecDeque<VirtualListItem>, items: VecDeque<VirtualListItem>,
content_size: Dynamic<Size<UPx>>, content_size: Dynamic<Size<UPx>>,
contents: Watcher, contents: Watcher,
@ -88,6 +91,18 @@ impl VirtualList {
let item_count = item_count.into_value().into_dynamic().into_reader(); let item_count = item_count.into_value().into_dynamic().into_reader();
let content_size = Dynamic::new(Size::default()); let content_size = Dynamic::new(Size::default());
let x = scroll.map_each_cloned(|scroll| scroll.x);
x.for_each_cloned({
let scroll = scroll.clone();
move |x| {
if let Ok(mut scroll) = scroll.try_lock() {
if scroll.x != x {
scroll.x = x;
}
}
}
})
.persist();
let y = scroll.map_each_cloned(|scroll| scroll.y); let y = scroll.map_each_cloned(|scroll| scroll.y);
y.for_each_cloned({ y.for_each_cloned({
let scroll = scroll.clone(); let scroll = scroll.clone();
@ -100,10 +115,12 @@ impl VirtualList {
} }
}) })
.persist(); .persist();
let vertical = ScrollBar::new(content_size.map_each_cloned(|size| size.height), y, true); let horizontal = ScrollBar::new(content_size.map_each_cloned(|size| size.width), x, false);
let max_scroll = vertical let mut vertical =
.max_scroll() ScrollBar::new(content_size.map_each_cloned(|size| size.height), y, true);
.map_each_cloned(|y| Point::new(UPx::ZERO, y)) vertical.synchronize_visibility_with(&horizontal);
let max_scroll = (&horizontal.max_scroll(), &vertical.max_scroll())
.map_each_cloned(|(x, y)| Point::new(x, y))
.into_reader(); .into_reader();
let contents = Watcher::default(); let contents = Watcher::default();
@ -114,6 +131,7 @@ impl VirtualList {
contents, contents,
contents_generation, contents_generation,
vertical_scroll: OwnedWidget::new(vertical), vertical_scroll: OwnedWidget::new(vertical),
horizontal_scroll: OwnedWidget::new(horizontal),
items: VecDeque::new(), items: VecDeque::new(),
control_size: Dynamic::new(Size::default()), control_size: Dynamic::new(Size::default()),
content_size, content_size,
@ -180,6 +198,52 @@ impl VirtualList {
} }
} }
fn layout_scrollbars(
&mut self,
available_space: Size<ConstraintLimit>,
new_control_size: Size<UPx>,
context: &mut LayoutContext<'_, '_, '_, '_>,
) {
let horizontal = self
.horizontal_scroll
.make_if_needed()
.mounted(&mut context.as_event_context());
let scrollbar_layout = context.for_other(&horizontal).layout(available_space);
context.set_child_layout(
&horizontal,
Rect::new(
Point::new(
Px::ZERO,
available_space
.height
.fit_measured(new_control_size.height)
.saturating_sub(scrollbar_layout.height)
.into_signed(),
),
scrollbar_layout.into_signed(),
),
);
let vertical = self
.vertical_scroll
.make_if_needed()
.mounted(&mut context.as_event_context());
let scrollbar_layout = context.for_other(&vertical).layout(available_space);
context.set_child_layout(
&vertical,
Rect::new(
Point::new(
available_space
.width
.fit_measured(new_control_size.width)
.saturating_sub(scrollbar_layout.width)
.into_signed(),
Px::ZERO,
),
scrollbar_layout.into_signed(),
),
);
}
fn layout_rows( fn layout_rows(
&mut self, &mut self,
item_count: usize, item_count: usize,
@ -205,29 +269,12 @@ impl VirtualList {
item_size.width = new_control_size.width; item_size.width = new_control_size.width;
} }
let vertical = self self.layout_scrollbars(available_space, new_control_size, context);
.vertical_scroll
.make_if_needed()
.mounted(&mut context.as_event_context());
let scrollbar_layout = context.for_other(&vertical).layout(available_space);
context.set_child_layout(
&vertical,
Rect::new(
Point::new(
available_space
.width
.fit_measured(new_control_size.width)
.saturating_sub(scrollbar_layout.width)
.into_signed(),
Px::ZERO,
),
scrollbar_layout.into_signed(),
),
);
let scroll = self.scroll.get_tracking_invalidate(context); let scroll = self.scroll.get_tracking_invalidate(context);
let max_scroll_x = item_size.width.saturating_sub(new_control_size.width);
let max_scroll_y = content_height.saturating_sub(new_control_size.height); let max_scroll_y = content_height.saturating_sub(new_control_size.height);
let scroll = scroll.min(Point::new(UPx::MAX, max_scroll_y)); let scroll = scroll.min(Point::new(max_scroll_x, max_scroll_y));
let start_item = (scroll.y.floor() / item_size.height).floor().get() as usize; let start_item = (scroll.y.floor() / item_size.height).floor().get() as usize;
let end_item = ((scroll.y.ceil() + new_control_size.height) / item_size.height) let end_item = ((scroll.y.ceil() + new_control_size.height) / item_size.height)
@ -271,20 +318,15 @@ impl VirtualList {
); );
} }
// TODO add % to Figures let x = -scroll.x.into_signed();
let mut y = let mut y = -(scroll.y % item_size.height).into_signed();
-UPx::from_unscaled(scroll.y.into_unscaled() % item_size.height.into_unscaled()) let constraint = item_size.map(ConstraintLimit::Fill);
.into_signed();
let constraint = item_size.map(ConstraintLimit::SizeToFit);
for item in &self.items { for item in &self.items {
let child_size = context.for_other(&item.mounted).layout(constraint); let child_size = context.for_other(&item.mounted).layout(constraint);
context.set_child_layout( context.set_child_layout(
&item.mounted, &item.mounted,
Rect::new( Rect::new(Point::new(x, y), item_size.min(child_size).into_signed()),
Point::new(Px::ZERO, y),
item_size.min(child_size).into_signed(),
),
); );
y += item_size.height.into_signed(); y += item_size.height.into_signed();
} }
@ -354,6 +396,11 @@ impl Widget for VirtualList {
.expect_made_mut() .expect_made_mut()
.mounted(&mut context.as_event_context()); .mounted(&mut context.as_event_context());
context.for_other(&vertical).redraw(); context.for_other(&vertical).redraw();
let horizontal = self
.horizontal_scroll
.expect_made_mut()
.mounted(&mut context.as_event_context());
context.for_other(&horizontal).redraw();
} }
fn layout( fn layout(
@ -384,6 +431,12 @@ impl Widget for VirtualList {
.expect("a ScrollBar") .expect("a ScrollBar")
.mouse_wheel(delta, context) .mouse_wheel(delta, context)
.is_break(); .is_break();
let mut horizontal = self.horizontal_scroll.expect_made().widget().lock();
handled |= horizontal
.downcast_mut::<ScrollBar>()
.expect("a ScrollBar")
.mouse_wheel(delta, context)
.is_break();
} }
if handled { if handled {
self.show_scrollbars(context); self.show_scrollbars(context);