mirror of https://github.com/linebender/xilem
Label with animated variable font parameters (#507)
This adds a new `VariableLabel` widget, which animates its weight to a target value in a linear fashion (over a fixed time period). Also adds support for this in Xilem, and a new `variable_clock` example. This example also runs on Android. [Screencast_20240812_171138.webm](https://github.com/user-attachments/assets/5df623f9-f4ca-4b55-b6a9-2047d2581b56) Current status: The code in Xilem and Masonry library crates is final. I'm planning on significantly updating the actual example. Outstanding issues: - [X] Hacks in support for "Roboto Flex", by always loading it from the local file - resolved - [X] It's not clear what subset of Roboto Flex we should use - still open to bikeshedding - [ ] The variable font animation support is not really as generic as it should be. This starts to drift quite close to a styling question, however. - [ ] The only supported variable axis is `wgth`
This commit is contained in:
parent
052ac39667
commit
3fd3903eae
|
@ -22,7 +22,7 @@ env:
|
|||
RUST_MIN_VER_WASM_PKGS: "-p xilem_core"
|
||||
|
||||
# Only some of our examples support Android (primarily due to extra required boilerplate).
|
||||
ANDROID_TARGETS: -p xilem --example mason_android --example calc_android --example stopwatch_android
|
||||
ANDROID_TARGETS: -p xilem --example mason_android --example calc_android --example stopwatch_android --example variable_clock_android
|
||||
|
||||
# We do not run the masonry snapshot tests, because those currently require a specific font stack
|
||||
# See https://github.com/linebender/xilem/pull/233
|
||||
|
|
|
@ -18,6 +18,7 @@ extend-ignore-re = [
|
|||
|
||||
[default.extend-identifiers]
|
||||
FillStrat = "FillStrat" # short for strategy
|
||||
wdth = "wdth" # Variable font parameter
|
||||
|
||||
# Case insensitive
|
||||
[default.extend-words]
|
||||
|
|
|
@ -2092,6 +2092,15 @@ dependencies = [
|
|||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nv-flip"
|
||||
version = "0.1.2"
|
||||
|
@ -3163,7 +3172,9 @@ checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
|
|||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
|
@ -4233,9 +4244,9 @@ name = "xilem"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_winit",
|
||||
"masonry",
|
||||
"smallvec",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"vello",
|
||||
|
|
|
@ -122,3 +122,4 @@ bitflags = "2.6.0"
|
|||
accesskit = "0.16.0"
|
||||
accesskit_winit = "0.22.0"
|
||||
nv-flip = "0.1.2"
|
||||
time = "0.3.36"
|
||||
|
|
|
@ -157,6 +157,9 @@ cargo update -p package_name --precise 0.1.1
|
|||
Licensed under the Apache License, Version 2.0
|
||||
([LICENSE](LICENSE) or <http://www.apache.org/licenses/LICENSE-2.0>)
|
||||
|
||||
The font file (`RobotoFlex-Subset.ttf`) in `xilem/resources/fonts/roboto_flex/` is licensed solely as documented in that folder,
|
||||
(and is not licensed under the Apache License, Version 2.0).
|
||||
|
||||
## Contribution
|
||||
|
||||
Contributions are welcome by pull request. The [Rust code of conduct] applies.
|
||||
|
|
|
@ -39,7 +39,7 @@ xi-unicode = "0.3.0"
|
|||
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "time"] }
|
||||
accesskit.workspace = true
|
||||
accesskit_winit.workspace = true
|
||||
time = { version = "0.3.36", features = ["macros", "formatting"] }
|
||||
time = { workspace = true, features = ["macros", "formatting"] }
|
||||
cursor-icon = "1.1.0"
|
||||
dpi.workspace = true
|
||||
nv-flip.workspace = true
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright 2024 the Xilem Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::event_loop_runner::MasonryState;
|
||||
use crate::widget::WidgetMut;
|
||||
use crate::{Action, Widget, WidgetId};
|
||||
|
||||
|
@ -18,6 +19,12 @@ pub struct DriverCtx<'a> {
|
|||
|
||||
pub trait AppDriver {
|
||||
fn on_action(&mut self, ctx: &mut DriverCtx<'_>, widget_id: WidgetId, action: Action);
|
||||
|
||||
#[allow(unused_variables)]
|
||||
/// A hook which will be executed when the application starts, to allow initial configuration of the `MasonryState`.
|
||||
///
|
||||
/// Use cases include loading fonts.
|
||||
fn on_start(&mut self, state: &mut MasonryState) {}
|
||||
}
|
||||
|
||||
impl<'a> DriverCtx<'a> {
|
||||
|
|
|
@ -130,17 +130,20 @@ pub fn run_with(
|
|||
app_driver: impl AppDriver + 'static,
|
||||
background_color: Color,
|
||||
) -> Result<(), EventLoopError> {
|
||||
let mut main_state = MainState {
|
||||
masonry_state: MasonryState::new(window, &event_loop, root_widget, background_color),
|
||||
app_driver: Box::new(app_driver),
|
||||
};
|
||||
|
||||
// If there is no default tracing subscriber, we set our own. If one has
|
||||
// already been set, we get an error which we swallow.
|
||||
// By now, we're about to take control of the event loop. The user is unlikely
|
||||
// to try to set their own subscriber once the event loop has started.
|
||||
let _ = crate::tracing_backend::try_init_tracing();
|
||||
|
||||
let mut main_state = MainState {
|
||||
masonry_state: MasonryState::new(window, &event_loop, root_widget, background_color),
|
||||
app_driver: Box::new(app_driver),
|
||||
};
|
||||
main_state
|
||||
.app_driver
|
||||
.on_start(&mut main_state.masonry_state);
|
||||
|
||||
event_loop.run_app(&mut main_state)
|
||||
}
|
||||
|
||||
|
|
|
@ -198,8 +198,9 @@ impl RenderRoot {
|
|||
// See https://github.com/linebender/druid/issues/85 for discussion.
|
||||
let last = self.last_anim.take();
|
||||
let elapsed_ns = last.map(|t| now.duration_since(t).as_nanos()).unwrap_or(0) as u64;
|
||||
|
||||
if self.root_state().request_anim {
|
||||
let root_state = self.root_state();
|
||||
if root_state.request_anim {
|
||||
root_state.request_anim = false;
|
||||
self.root_lifecycle(LifeCycle::AnimFrame(elapsed_ns));
|
||||
self.last_anim = Some(now);
|
||||
}
|
||||
|
@ -224,7 +225,22 @@ impl RenderRoot {
|
|||
self.root_on_text_event(event)
|
||||
}
|
||||
|
||||
/// Registers all fonts that exist in the given data.
|
||||
///
|
||||
/// Returns a list of pairs each containing the family identifier and fonts
|
||||
/// added to that family.
|
||||
pub fn register_fonts(
|
||||
&mut self,
|
||||
data: Vec<u8>,
|
||||
) -> Vec<(fontique::FamilyId, Vec<fontique::FontInfo>)> {
|
||||
self.state.font_context.collection.register_fonts(data)
|
||||
}
|
||||
|
||||
/// Add a font from its raw data for use in tests.
|
||||
/// The font is added to the fallback chain for Latin scripts.
|
||||
/// This is expected to be used with
|
||||
/// [`RenderRootOptions.use_system_fonts = false`](RenderRootOptions::use_system_fonts)
|
||||
/// to ensure rendering is consistent cross-platform.
|
||||
///
|
||||
/// We expect to develop a much more fully-featured font API in the future, but
|
||||
/// this is necessary for our testing of Masonry.
|
||||
|
@ -232,8 +248,9 @@ impl RenderRoot {
|
|||
&mut self,
|
||||
data: Vec<u8>,
|
||||
) -> Vec<(fontique::FamilyId, Vec<fontique::FontInfo>)> {
|
||||
let families = self.state.font_context.collection.register_fonts(data);
|
||||
// TODO: This code doesn't *seem* reasonable
|
||||
let families = self.register_fonts(data);
|
||||
// Make sure that all of these fonts are in the fallback chain for the Latin script.
|
||||
// <https://en.wikipedia.org/wiki/Script_(Unicode)#Latn>
|
||||
self.state
|
||||
.font_context
|
||||
.collection
|
||||
|
|
|
@ -229,6 +229,7 @@ impl<T> TextLayout<T> {
|
|||
///
|
||||
/// This does not account for things like the text changing, handling that
|
||||
/// is the responsibility of the user.
|
||||
#[must_use = "Has no side effects"]
|
||||
pub fn needs_rebuild(&self) -> bool {
|
||||
self.needs_layout || self.needs_line_breaks
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ mod sized_box;
|
|||
mod spinner;
|
||||
mod split;
|
||||
mod textbox;
|
||||
mod variable_label;
|
||||
mod widget_arena;
|
||||
|
||||
pub use self::image::Image;
|
||||
|
@ -44,6 +45,7 @@ pub use sized_box::SizedBox;
|
|||
pub use spinner::Spinner;
|
||||
pub use split::Split;
|
||||
pub use textbox::Textbox;
|
||||
pub use variable_label::VariableLabel;
|
||||
pub use widget_mut::WidgetMut;
|
||||
pub use widget_pod::WidgetPod;
|
||||
pub use widget_ref::WidgetRef;
|
||||
|
|
|
@ -0,0 +1,414 @@
|
|||
// Copyright 2019 the Xilem Authors and the Druid Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! A label with support for animated variable font properties
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use accesskit::Role;
|
||||
use parley::fontique::Weight;
|
||||
use parley::layout::Alignment;
|
||||
use parley::style::{FontFamily, FontStack};
|
||||
use smallvec::SmallVec;
|
||||
use tracing::{trace, trace_span, Span};
|
||||
use vello::kurbo::{Affine, Point, Size};
|
||||
use vello::peniko::BlendMode;
|
||||
use vello::Scene;
|
||||
|
||||
use crate::text::{TextBrush, TextLayout, TextStorage};
|
||||
use crate::widget::WidgetMut;
|
||||
use crate::{
|
||||
AccessCtx, AccessEvent, ArcStr, BoxConstraints, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx,
|
||||
PaintCtx, PointerEvent, StatusChange, TextEvent, Widget, WidgetId,
|
||||
};
|
||||
|
||||
use super::LineBreaking;
|
||||
|
||||
// added padding between the edges of the widget and the text.
|
||||
pub(super) const LABEL_X_PADDING: f64 = 2.0;
|
||||
|
||||
/// An `f32` value which can move towards a target value at a linear rate over time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AnimatedF32 {
|
||||
/// The value which self will eventually reach.
|
||||
target: f32,
|
||||
/// The current value
|
||||
value: f32,
|
||||
// TODO: Provide different easing functions, instead of just linear
|
||||
/// The change in value every millisecond, which will not change over the lifetime of the value.
|
||||
rate_per_millisecond: f32,
|
||||
}
|
||||
|
||||
impl AnimatedF32 {
|
||||
/// Create a value which is not changing.
|
||||
pub fn stable(value: f32) -> Self {
|
||||
assert!(value.is_finite());
|
||||
AnimatedF32 {
|
||||
target: value,
|
||||
value,
|
||||
rate_per_millisecond: 0.,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move this value to the `target` over `over_millis` milliseconds.
|
||||
/// Might change the current value, if `over_millis` is zero.
|
||||
///
|
||||
/// `over_millis` should be non-negative.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If `target` is not a finite value.
|
||||
pub fn move_to(&mut self, target: f32, over_millis: f32) {
|
||||
assert!(target.is_finite());
|
||||
self.target = target;
|
||||
match over_millis.partial_cmp(&0.) {
|
||||
Some(Ordering::Equal) => self.value = target,
|
||||
Some(Ordering::Less) => {
|
||||
tracing::warn!("move_to: provided negative time step {over_millis}");
|
||||
self.value = target;
|
||||
}
|
||||
Some(Ordering::Greater) => {
|
||||
// Since over_millis is positive, we know that this vector is in the direction of the `target`.
|
||||
self.rate_per_millisecond = (self.target - self.value) / over_millis;
|
||||
debug_assert!(
|
||||
self.rate_per_millisecond.is_finite(),
|
||||
"Calculated invalid rate despite valid inputs. Current value is {}",
|
||||
self.value
|
||||
);
|
||||
}
|
||||
None => panic!("Provided invalid time step {over_millis}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance this animation by `by_millis` milliseconds.
|
||||
///
|
||||
/// Returns the status of the animation after this advancement.
|
||||
pub fn advance(&mut self, by_millis: f32) -> AnimationStatus {
|
||||
if !self.value.is_finite() {
|
||||
tracing::error!("Got unexpected non-finite value {}", self.value);
|
||||
debug_assert!(self.target.is_finite());
|
||||
self.value = self.target;
|
||||
}
|
||||
|
||||
let original_side = self
|
||||
.value
|
||||
.partial_cmp(&self.target)
|
||||
.expect("Target and value are not NaN.");
|
||||
|
||||
self.value += self.rate_per_millisecond * by_millis;
|
||||
let other_side = self
|
||||
.value
|
||||
.partial_cmp(&self.target)
|
||||
.expect("Target and value are not NaN.");
|
||||
|
||||
if other_side.is_eq() || original_side != other_side {
|
||||
self.value = self.target;
|
||||
self.rate_per_millisecond = 0.;
|
||||
AnimationStatus::Completed
|
||||
} else {
|
||||
AnimationStatus::Ongoing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The status an animation can be in.
|
||||
///
|
||||
/// Generally returned when an animation is advanced, to determine whether.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AnimationStatus {
|
||||
/// The animation has finished.
|
||||
Completed,
|
||||
/// The animation is still running
|
||||
Ongoing,
|
||||
}
|
||||
|
||||
impl AnimationStatus {
|
||||
pub fn is_completed(self) -> bool {
|
||||
matches!(self, AnimationStatus::Completed)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Make this a wrapper (around `Label`?)
|
||||
/// A widget displaying non-editable text, with a variable [weight](parley::style::FontWeight).
|
||||
pub struct VariableLabel {
|
||||
text_layout: TextLayout<ArcStr>,
|
||||
line_break_mode: LineBreaking,
|
||||
show_disabled: bool,
|
||||
brush: TextBrush,
|
||||
weight: AnimatedF32,
|
||||
}
|
||||
|
||||
// --- MARK: BUILDERS ---
|
||||
impl VariableLabel {
|
||||
/// Create a new label.
|
||||
pub fn new(text: impl Into<ArcStr>) -> Self {
|
||||
Self {
|
||||
text_layout: TextLayout::new(text.into(), crate::theme::TEXT_SIZE_NORMAL as f32),
|
||||
line_break_mode: LineBreaking::Overflow,
|
||||
show_disabled: true,
|
||||
brush: crate::theme::TEXT_COLOR.into(),
|
||||
weight: AnimatedF32::stable(Weight::NORMAL.value()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &ArcStr {
|
||||
self.text_layout.text()
|
||||
}
|
||||
|
||||
#[doc(alias = "with_text_color")]
|
||||
pub fn with_text_brush(mut self, brush: impl Into<TextBrush>) -> Self {
|
||||
self.text_layout.set_brush(brush);
|
||||
self
|
||||
}
|
||||
|
||||
#[doc(alias = "with_font_size")]
|
||||
pub fn with_text_size(mut self, size: f32) -> Self {
|
||||
self.text_layout.set_text_size(size);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_text_alignment(mut self, alignment: Alignment) -> Self {
|
||||
self.text_layout.set_text_alignment(alignment);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_font(mut self, font: FontStack<'static>) -> Self {
|
||||
self.text_layout.set_font(font);
|
||||
self
|
||||
}
|
||||
pub fn with_font_family(self, font: FontFamily<'static>) -> Self {
|
||||
self.with_font(FontStack::Single(font))
|
||||
}
|
||||
|
||||
pub fn with_line_break_mode(mut self, line_break_mode: LineBreaking) -> Self {
|
||||
self.line_break_mode = line_break_mode;
|
||||
self
|
||||
}
|
||||
/// Set the initial font weight for this text.
|
||||
pub fn with_initial_weight(mut self, weight: f32) -> Self {
|
||||
self.weight.value = weight;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a label with empty text.
|
||||
pub fn empty() -> Self {
|
||||
Self::new("")
|
||||
}
|
||||
}
|
||||
|
||||
// --- MARK: WIDGETMUT ---
|
||||
impl WidgetMut<'_, VariableLabel> {
|
||||
/// Read the text.
|
||||
pub fn text(&self) -> &ArcStr {
|
||||
self.widget.text_layout.text()
|
||||
}
|
||||
|
||||
/// Set a property on the underlying text.
|
||||
///
|
||||
/// This cannot be used to set attributes.
|
||||
pub fn set_text_properties<R>(&mut self, f: impl FnOnce(&mut TextLayout<ArcStr>) -> R) -> R {
|
||||
let ret = f(&mut self.widget.text_layout);
|
||||
if self.widget.text_layout.needs_rebuild() {
|
||||
self.ctx.request_layout();
|
||||
self.ctx.request_paint();
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
/// Modify the underlying text.
|
||||
pub fn set_text(&mut self, new_text: impl Into<ArcStr>) {
|
||||
let new_text = new_text.into();
|
||||
self.set_text_properties(|layout| layout.set_text(new_text));
|
||||
}
|
||||
|
||||
#[doc(alias = "set_text_color")]
|
||||
/// Set the brush of the text, normally used for the colour.
|
||||
pub fn set_text_brush(&mut self, brush: impl Into<TextBrush>) {
|
||||
let brush = brush.into();
|
||||
self.widget.brush = brush;
|
||||
if !self.ctx.is_disabled() {
|
||||
let brush = self.widget.brush.clone();
|
||||
self.set_text_properties(|layout| layout.set_brush(brush));
|
||||
}
|
||||
}
|
||||
/// Set the font size for this text.
|
||||
pub fn set_text_size(&mut self, size: f32) {
|
||||
self.set_text_properties(|layout| layout.set_text_size(size));
|
||||
}
|
||||
/// Set the text alignment of the contained text
|
||||
pub fn set_alignment(&mut self, alignment: Alignment) {
|
||||
self.set_text_properties(|layout| layout.set_text_alignment(alignment));
|
||||
}
|
||||
/// Set the font (potentially with fallbacks) which will be used for this text.
|
||||
pub fn set_font(&mut self, font_stack: FontStack<'static>) {
|
||||
self.set_text_properties(|layout| layout.set_font(font_stack));
|
||||
}
|
||||
/// A helper method to use a single font family.
|
||||
pub fn set_font_family(&mut self, family: FontFamily<'static>) {
|
||||
self.set_font(FontStack::Single(family));
|
||||
}
|
||||
/// How to handle overflowing lines.
|
||||
pub fn set_line_break_mode(&mut self, line_break_mode: LineBreaking) {
|
||||
self.widget.line_break_mode = line_break_mode;
|
||||
self.ctx.request_paint();
|
||||
}
|
||||
/// Set the weight which this font will target.
|
||||
pub fn set_target_weight(&mut self, target: f32, over_millis: f32) {
|
||||
self.widget.weight.move_to(target, over_millis);
|
||||
self.ctx.request_layout();
|
||||
self.ctx.request_paint();
|
||||
self.ctx.request_anim_frame();
|
||||
}
|
||||
}
|
||||
|
||||
// --- MARK: IMPL WIDGET ---
|
||||
impl Widget for VariableLabel {
|
||||
fn on_pointer_event(&mut self, _ctx: &mut EventCtx, event: &PointerEvent) {
|
||||
match event {
|
||||
PointerEvent::PointerMove(_point) => {
|
||||
// TODO: Set cursor if over link
|
||||
}
|
||||
PointerEvent::PointerDown(_button, _state) => {
|
||||
// TODO: Start tracking currently pressed
|
||||
// (i.e. don't press)
|
||||
}
|
||||
PointerEvent::PointerUp(_button, _state) => {
|
||||
// TODO: Follow link (if not now dragging ?)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {
|
||||
// If focused on a link and enter pressed, follow it?
|
||||
// TODO: This sure looks like each link needs its own widget, although I guess the challenge there is
|
||||
// that the bounding boxes can go e.g. across line boundaries?
|
||||
}
|
||||
|
||||
fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
fn on_status_change(&mut self, _ctx: &mut LifeCycleCtx, event: &StatusChange) {
|
||||
match event {
|
||||
StatusChange::FocusChanged(_) => {
|
||||
// TODO: Focus on first link
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle) {
|
||||
match event {
|
||||
LifeCycle::DisabledChanged(disabled) => {
|
||||
if self.show_disabled {
|
||||
if *disabled {
|
||||
self.text_layout
|
||||
.set_brush(crate::theme::DISABLED_TEXT_COLOR);
|
||||
} else {
|
||||
self.text_layout.set_brush(self.brush.clone());
|
||||
}
|
||||
}
|
||||
// TODO: Parley seems to require a relayout when colours change
|
||||
ctx.request_layout();
|
||||
}
|
||||
LifeCycle::BuildFocusChain => {
|
||||
if !self.text_layout.text().links().is_empty() {
|
||||
tracing::warn!("Links present in text, but not yet integrated");
|
||||
}
|
||||
}
|
||||
LifeCycle::AnimFrame(time) => {
|
||||
let millis = (*time as f64 / 1_000_000.) as f32;
|
||||
let result = self.weight.advance(millis);
|
||||
self.text_layout.invalidate();
|
||||
if !result.is_completed() {
|
||||
ctx.request_anim_frame();
|
||||
}
|
||||
ctx.request_layout();
|
||||
ctx.request_paint();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
|
||||
// Compute max_advance from box constraints
|
||||
let max_advance = if self.line_break_mode != LineBreaking::WordWrap {
|
||||
None
|
||||
} else if bc.max().width.is_finite() {
|
||||
Some(bc.max().width as f32 - 2. * LABEL_X_PADDING as f32)
|
||||
} else if bc.min().width.is_sign_negative() {
|
||||
Some(0.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.text_layout.set_max_advance(max_advance);
|
||||
if self.text_layout.needs_rebuild() {
|
||||
let (font_ctx, layout_ctx) = ctx.text_contexts();
|
||||
self.text_layout
|
||||
.rebuild_with_attributes(font_ctx, layout_ctx, |mut builder| {
|
||||
builder.push_default(&parley::style::StyleProperty::FontWeight(Weight::new(
|
||||
self.weight.value,
|
||||
)));
|
||||
// builder.push_default(&parley::style::StyleProperty::FontVariations(
|
||||
// parley::style::FontSettings::List(&[]),
|
||||
// ));
|
||||
builder
|
||||
});
|
||||
}
|
||||
// We ignore trailing whitespace for a label
|
||||
let text_size = self.text_layout.size();
|
||||
let label_size = Size {
|
||||
height: text_size.height,
|
||||
width: text_size.width + 2. * LABEL_X_PADDING,
|
||||
};
|
||||
let size = bc.constrain(label_size);
|
||||
trace!(
|
||||
"Computed layout: max={:?}. w={}, h={}",
|
||||
max_advance,
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
size
|
||||
}
|
||||
|
||||
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) {
|
||||
if self.text_layout.needs_rebuild() {
|
||||
debug_panic!("Called Label paint before layout");
|
||||
}
|
||||
if self.line_break_mode == LineBreaking::Clip {
|
||||
let clip_rect = ctx.size().to_rect();
|
||||
scene.push_layer(BlendMode::default(), 1., Affine::IDENTITY, &clip_rect);
|
||||
}
|
||||
self.text_layout
|
||||
.draw(scene, Point::new(LABEL_X_PADDING, 0.0));
|
||||
|
||||
if self.line_break_mode == LineBreaking::Clip {
|
||||
scene.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_role(&self) -> Role {
|
||||
Role::Label
|
||||
}
|
||||
|
||||
fn accessibility(&mut self, ctx: &mut AccessCtx) {
|
||||
ctx.current_node()
|
||||
.set_name(self.text().as_str().to_string());
|
||||
}
|
||||
|
||||
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
|
||||
SmallVec::new()
|
||||
}
|
||||
|
||||
fn make_trace_span(&self) -> Span {
|
||||
trace_span!("Label")
|
||||
}
|
||||
|
||||
fn get_debug_text(&self) -> Option<String> {
|
||||
Some(self.text_layout.text().as_str().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// --- MARK: TESTS ---
|
||||
#[cfg(test)]
|
||||
mod tests {}
|
|
@ -386,7 +386,10 @@ impl<W: Widget> WidgetPod<W> {
|
|||
|
||||
true
|
||||
}
|
||||
LifeCycle::AnimFrame(_) => true,
|
||||
LifeCycle::AnimFrame(_) => {
|
||||
state.request_anim = false;
|
||||
true
|
||||
}
|
||||
LifeCycle::DisabledChanged(ancestors_disabled) => {
|
||||
state.update_focus_chain = true;
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
rust-version.workspace = true
|
||||
exclude = ["/resources/fonts/roboto_flex/"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
@ -44,6 +45,15 @@ path = "examples/stopwatch.rs"
|
|||
# cdylib is required for cargo-apk
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[[example]]
|
||||
name = "variable_clock"
|
||||
|
||||
[[example]]
|
||||
name = "variable_clock_android"
|
||||
path = "examples/variable_clock.rs"
|
||||
# cdylib is required for cargo-apk
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
@ -58,7 +68,8 @@ accesskit.workspace = true
|
|||
tokio = { version = "1.39.1", features = ["rt", "rt-multi-thread", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
accesskit_winit.workspace = true
|
||||
# Used for `variable_clock`
|
||||
time = { workspace = true, features = ["local-offset"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dev-dependencies]
|
||||
winit = { features = ["android-native-activity"], workspace = true }
|
||||
|
|
|
@ -64,6 +64,10 @@ Unless you explicitly state otherwise, any contribution intentionally submitted
|
|||
|
||||
Licensed under the Apache License, Version 2.0 ([LICENSE](LICENSE) or <http://www.apache.org/licenses/LICENSE-2.0>)
|
||||
|
||||
The font file (`RobotoFlex-Subset.ttf`) in `resources/fonts/roboto_flex/` is licensed solely as documented in that folder,
|
||||
(and is not licensed under the Apache License, Version 2.0).
|
||||
Note that this file is *not* distributed with the.
|
||||
|
||||
[Masonry]: https://crates.io/crates/masonry
|
||||
[Druid]: https://crates.io/crates/druid
|
||||
[Vello]: https://crates.io/crates/vello
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
// Copyright 2024 the Xilem Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! This example uses variable fonts in a touch sensitive digital clock.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use masonry::parley::{
|
||||
fontique::Weight,
|
||||
style::{FontFamily, FontStack},
|
||||
};
|
||||
use time::{error::IndeterminateOffset, macros::format_description, OffsetDateTime, UtcOffset};
|
||||
use winit::error::EventLoopError;
|
||||
use xilem::{
|
||||
view::{
|
||||
async_repeat, button, flex, label, prose, sized_box, variable_label, Axis, FlexExt,
|
||||
FlexSpacer,
|
||||
},
|
||||
Color, EventLoop, EventLoopBuilder, WidgetView, Xilem,
|
||||
};
|
||||
use xilem_core::fork;
|
||||
|
||||
/// The state of the application, owned by Xilem and updated by the callbacks below.
|
||||
struct Clocks {
|
||||
/// The font [weight](Weight) used for the values.
|
||||
weight: f32,
|
||||
/// The current UTC offset on this machine.
|
||||
local_offset: Result<UtcOffset, IndeterminateOffset>,
|
||||
/// The current time.
|
||||
now_utc: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// A possible timezone, with an offset from UTC.
|
||||
struct TimeZone {
|
||||
/// An approximate region which this offset applies to.
|
||||
region: &'static str,
|
||||
/// The offset from UTC
|
||||
offset: time::UtcOffset,
|
||||
}
|
||||
|
||||
fn app_logic(data: &mut Clocks) -> impl WidgetView<Clocks> {
|
||||
let view = flex((
|
||||
// HACK: We add a spacer at the top for Android. See https://github.com/rust-windowing/winit/issues/2308
|
||||
FlexSpacer::Fixed(40.),
|
||||
local_time(data),
|
||||
controls(),
|
||||
// TODO: When we get responsive layouts, move this into a two-column view.
|
||||
TIMEZONES.iter().map(|it| it.view(data)).collect::<Vec<_>>(),
|
||||
));
|
||||
fork(
|
||||
view,
|
||||
async_repeat(
|
||||
|proxy| async move {
|
||||
// TODO: Synchronise with the actual "second" interval. This is expected to show the wrong second
|
||||
// ~50% of the time.
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let Ok(()) = proxy.message(()) else {
|
||||
break;
|
||||
};
|
||||
}
|
||||
},
|
||||
|data: &mut Clocks, ()| data.now_utc = OffsetDateTime::now_utc(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Shows the current system time on a best-effort basis.
|
||||
// TODO: Maybe make this have a larger font size?
|
||||
fn local_time(data: &mut Clocks) -> impl WidgetView<Clocks> {
|
||||
let (error_view, offset) = if let Ok(offset) = data.local_offset {
|
||||
(None, offset)
|
||||
} else {
|
||||
(
|
||||
Some(prose("Could not determine local UTC offset, using UTC").brush(Color::ORANGE_RED)),
|
||||
UtcOffset::UTC,
|
||||
)
|
||||
};
|
||||
|
||||
flex((
|
||||
TimeZone {
|
||||
region: "Here",
|
||||
offset,
|
||||
}
|
||||
.view(data),
|
||||
error_view,
|
||||
))
|
||||
}
|
||||
|
||||
/// Controls for the variable font weight.
|
||||
fn controls() -> impl WidgetView<Clocks> {
|
||||
flex((
|
||||
button("Increase", |data: &mut Clocks| {
|
||||
data.weight = (data.weight + 100.).clamp(1., 1000.);
|
||||
}),
|
||||
button("Decrease", |data: &mut Clocks| {
|
||||
data.weight = (data.weight - 100.).clamp(1., 1000.);
|
||||
}),
|
||||
button("Minimum", |data: &mut Clocks| {
|
||||
data.weight = 1.;
|
||||
}),
|
||||
button("Maximum", |data: &mut Clocks| {
|
||||
data.weight = 1000.;
|
||||
}),
|
||||
))
|
||||
.direction(Axis::Horizontal)
|
||||
}
|
||||
|
||||
impl TimeZone {
|
||||
/// Display this timezone as a row, designed to be shown in a list of time zones.
|
||||
fn view(&self, data: &mut Clocks) -> impl WidgetView<Clocks> {
|
||||
let date_time_in_self = data.now_utc.to_offset(self.offset);
|
||||
sized_box(flex((
|
||||
flex((
|
||||
prose(self.region),
|
||||
FlexSpacer::Flex(1.),
|
||||
label(format!("UTC{}", self.offset)).brush(
|
||||
if data.local_offset.is_ok_and(|it| it == self.offset) {
|
||||
// TODO: Consider accessibility here.
|
||||
Color::ORANGE
|
||||
} else {
|
||||
masonry::theme::TEXT_COLOR
|
||||
},
|
||||
),
|
||||
))
|
||||
.must_fill_major_axis(true)
|
||||
.direction(Axis::Horizontal)
|
||||
.flex(1.),
|
||||
flex((
|
||||
variable_label(
|
||||
date_time_in_self
|
||||
.format(format_description!("[hour repr:24]:[minute]:[second]"))
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
)
|
||||
.text_size(48.)
|
||||
// Use the roboto flex we have just loaded.
|
||||
.with_font(FontStack::List(&[FontFamily::Named("Roboto Flex")]))
|
||||
.target_weight(data.weight, 400.),
|
||||
FlexSpacer::Flex(1.0),
|
||||
(data.local_now().date() != date_time_in_self.date()).then(|| {
|
||||
label(
|
||||
date_time_in_self
|
||||
.format(format_description!("([day] [month repr:short])"))
|
||||
.unwrap(),
|
||||
)
|
||||
}),
|
||||
))
|
||||
.direction(Axis::Horizontal),
|
||||
)))
|
||||
.expand_width()
|
||||
.height(72.)
|
||||
}
|
||||
}
|
||||
|
||||
impl Clocks {
|
||||
fn local_now(&self) -> OffsetDateTime {
|
||||
match self.local_offset {
|
||||
Ok(offset) => self.now_utc.to_offset(offset),
|
||||
Err(_) => self.now_utc,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A subset of [Roboto Flex](https://fonts.google.com/specimen/Roboto+Flex), used under the OFL.
|
||||
/// This is a variable font, and so can have its axes be animated.
|
||||
/// The version in the repository supports the numbers 0-9 and `:`, to this examples use of
|
||||
/// it for clocks.
|
||||
/// Full details can be found in `xilem/resources/fonts/roboto_flex/README` from
|
||||
/// the workspace root.
|
||||
const ROBOTO_FLEX: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/resources/fonts/roboto_flex/",
|
||||
// The full font file is *not* included in this repository, due to size constraints.
|
||||
// If you download the full font, you can use it by moving it into the roboto_flex folder,
|
||||
// then swapping which of the following two lines is commented out:
|
||||
// "RobotoFlex-VariableFont_GRAD,XOPQ,XTRA,YOPQ,YTAS,YTDE,YTFI,YTLC,YTUC,opsz,slnt,wdth,wght.ttf",
|
||||
"RobotoFlex-Subset.ttf"
|
||||
));
|
||||
|
||||
fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> {
|
||||
let data = Clocks {
|
||||
weight: Weight::BLACK.value(),
|
||||
// TODO: We can't get this on Android, because
|
||||
local_offset: UtcOffset::current_local_offset(),
|
||||
now_utc: OffsetDateTime::now_utc(),
|
||||
};
|
||||
|
||||
// Load Roboto Flex so that it can be used at runtime.
|
||||
let app = Xilem::new(data, app_logic).with_font(ROBOTO_FLEX);
|
||||
|
||||
app.run_windowed(event_loop, "Clocks".into())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A shorthand for creating a [`TimeZone`].
|
||||
const fn tz(region: &'static str, offset: i8) -> TimeZone {
|
||||
TimeZone {
|
||||
region,
|
||||
offset: match time::UtcOffset::from_hms(offset, 0, 0) {
|
||||
Ok(it) => it,
|
||||
Err(_) => {
|
||||
panic!("Component out of range.");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A static list of timezones to display. All regions selected do not observe daylight savings time.
|
||||
///
|
||||
/// The timezones were determined on 2024-08-14.
|
||||
const TIMEZONES: &[TimeZone] = &[
|
||||
tz("Hawaii", -10),
|
||||
tz("Pitcairn Islands", -8),
|
||||
tz("Arizona", -7),
|
||||
tz("Saskatchewan", -6),
|
||||
tz("Peru", -5),
|
||||
tz("Barbados", -4),
|
||||
tz("Martinique", -4),
|
||||
tz("Uruguay", -3),
|
||||
tz("Iceland", 0),
|
||||
tz("Tunisia", 1),
|
||||
tz("Mozambique", 2),
|
||||
tz("Qatar", 3),
|
||||
tz("Azerbaijan", 4),
|
||||
tz("Pakistan", 5),
|
||||
tz("Bangladesh", 6),
|
||||
tz("Thailand", 7),
|
||||
tz("Singapore", 8),
|
||||
tz("Japan", 9),
|
||||
tz("Queensland", 10),
|
||||
tz("Tonga", 13),
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[allow(dead_code)]
|
||||
// This is treated as dead code by the Android version of the example, but is actually live
|
||||
// This hackery is required because Cargo doesn't care to support this use case, of one
|
||||
// example which works across Android and desktop
|
||||
fn main() -> Result<(), EventLoopError> {
|
||||
run(EventLoop::with_user_event())
|
||||
}
|
||||
|
||||
// Boilerplate code for android: Identical across all applications
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
// Safety: We are following `android_activity`'s docs here
|
||||
// We believe that there are no other declarations using this name in the compiled objects here
|
||||
#[allow(unsafe_code)]
|
||||
#[no_mangle]
|
||||
fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
||||
use winit::platform::android::EventLoopBuilderExtAndroid;
|
||||
|
||||
let mut event_loop = EventLoop::with_user_event();
|
||||
event_loop.with_android_app(app);
|
||||
|
||||
run(event_loop).expect("Can create app");
|
||||
}
|
||||
|
||||
// TODO: This is a hack because of how we handle our examples in Cargo.toml
|
||||
// Ideally, we change Cargo to be more sensible here?
|
||||
#[cfg(target_os = "android")]
|
||||
#[allow(dead_code)]
|
||||
fn main() {
|
||||
unreachable!()
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
*
|
||||
!/.gitignore
|
||||
!/OFL.txt
|
||||
!/RobotoFlex-Subset.ttf
|
||||
!/README.md
|
||||
|
||||
# TODO!
|
||||
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2017 The Roboto Flex Project Authors (https://github.com/TypeNetwork/Roboto-Flex)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
@ -0,0 +1,39 @@
|
|||
# Roboto Flex Subset
|
||||
|
||||
Roboto Flex is a variable font, which is used to validate Masonry's support for variable fonts.
|
||||
This is used in the `variable_clock` example, and has a subset chosen to also be usable by the `calc` example (although it is not currently used there).
|
||||
|
||||
Roboto Flex is a variable font with these axes:
|
||||
GRAD
|
||||
XOPQ
|
||||
XTRA
|
||||
YOPQ
|
||||
YTAS
|
||||
YTDE
|
||||
YTFI
|
||||
YTLC
|
||||
YTUC
|
||||
opsz
|
||||
slnt
|
||||
wdth
|
||||
wght
|
||||
|
||||
The subset was created using the command:
|
||||
|
||||
```shell
|
||||
> fonttools subset --text="0123456789:-/+=÷×±()" --output-file=RobotoFlex-Subset.ttf RobotoFlex-VariableFont_GRAD,XOPQ,XTRA,YOPQ,YTAS,YTDE,YTFI,YTLC,YTUC,opsz,slnt,wdth,wght.ttf --layout-features='*'
|
||||
```
|
||||
|
||||
with RobotoFlex [downloaded from Google Fonts](https://fonts.google.com/specimen/Roboto+Flex) in early August 2024:
|
||||
|
||||
```shell
|
||||
> sha256hmac RobotoFlex-VariableFont_GRAD,XOPQ,XTRA,YOPQ,YTAS,YTDE,YTFI,YTLC,YTUC,opsz,slnt,wdth,wght.ttf
|
||||
9efabb32d95826786ba339a43b6e574660fab1cad4dbf9b9e5e45aaef9d1eec3 RobotoFlex-VariableFont_GRAD,XOPQ,XTRA,YOPQ,YTAS,YTDE,YTFI,YTLC,YTUC,opsz,slnt,wdth,wght.ttf
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
RobotoFlex-Subset.ttf: Copyright 2017 The Roboto Flex Project Authors (<https://github.com/TypeNetwork/Roboto-Flex>)
|
||||
|
||||
Please read the full license text ([OFL.txt](./OFL.txt)) to understand the permissions,
|
||||
restrictions and requirements for usage, redistribution, and modification.
|
Binary file not shown.
|
@ -19,6 +19,8 @@ pub struct MasonryDriver<State, Logic, View, ViewState> {
|
|||
pub(crate) current_view: View,
|
||||
pub(crate) ctx: ViewCtx,
|
||||
pub(crate) view_state: ViewState,
|
||||
// Fonts which will be registered on startup.
|
||||
pub(crate) fonts: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// The `WidgetId` which async events should be sent to.
|
||||
|
@ -122,4 +124,14 @@ where
|
|||
self.current_view = next_view;
|
||||
}
|
||||
}
|
||||
fn on_start(&mut self, state: &mut event_loop_runner::MasonryState) {
|
||||
let root = state.get_root();
|
||||
// Register all provided fonts
|
||||
// self.fonts is never used again, so we may as well deallocate it.
|
||||
for font in std::mem::take(&mut self.fonts).drain(..) {
|
||||
// We currently don't do anything with the resulting family information,
|
||||
// because we don't have an easy way to return this to the application.
|
||||
drop(root.register_fonts(font));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,6 +47,8 @@ pub struct Xilem<State, Logic> {
|
|||
logic: Logic,
|
||||
runtime: tokio::runtime::Runtime,
|
||||
background_color: Color,
|
||||
// Font data to include in loading.
|
||||
fonts: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<State, Logic, View> Xilem<State, Logic>
|
||||
|
@ -61,9 +63,18 @@ where
|
|||
logic,
|
||||
runtime,
|
||||
background_color: Color::BLACK,
|
||||
fonts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a font when this `Xilem` is run.
|
||||
///
|
||||
/// This is an interim API whilst font lifecycles are determined.
|
||||
pub fn with_font(mut self, data: impl Into<Vec<u8>>) -> Self {
|
||||
self.fonts.push(data.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets main window background color.
|
||||
pub fn background_color(mut self, color: Color) -> Self {
|
||||
self.background_color = color;
|
||||
|
@ -132,6 +143,7 @@ where
|
|||
state: self.state,
|
||||
ctx,
|
||||
view_state,
|
||||
fonts: self.fonts,
|
||||
};
|
||||
(root_widget, driver)
|
||||
}
|
||||
|
|
|
@ -22,6 +22,9 @@ pub use spinner::*;
|
|||
mod label;
|
||||
pub use label::*;
|
||||
|
||||
mod variable_label;
|
||||
pub use variable_label::*;
|
||||
|
||||
mod prose;
|
||||
pub use prose::*;
|
||||
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
// Copyright 2024 the Xilem Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use masonry::{
|
||||
parley::{
|
||||
fontique::Weight,
|
||||
style::{FontFamily, FontStack, GenericFamily},
|
||||
},
|
||||
text::TextBrush,
|
||||
widget, ArcStr,
|
||||
};
|
||||
use xilem_core::{Mut, ViewMarker};
|
||||
|
||||
use crate::{Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
|
||||
|
||||
/// A view for displaying non-editable text, with a variable [weight](masonry::parley::style::FontWeight).
|
||||
pub fn variable_label(label: impl Into<ArcStr>) -> VariableLabel {
|
||||
VariableLabel {
|
||||
label: label.into(),
|
||||
text_brush: Color::WHITE.into(),
|
||||
alignment: TextAlignment::default(),
|
||||
text_size: masonry::theme::TEXT_SIZE_NORMAL as f32,
|
||||
target_weight: Weight::NORMAL,
|
||||
over_millis: 0.,
|
||||
font: FontStack::Single(FontFamily::Generic(GenericFamily::SystemUi)),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VariableLabel {
|
||||
label: ArcStr,
|
||||
|
||||
text_brush: TextBrush,
|
||||
alignment: TextAlignment,
|
||||
text_size: f32,
|
||||
target_weight: Weight,
|
||||
over_millis: f32,
|
||||
font: FontStack<'static>,
|
||||
// TODO: add more attributes of `masonry::widget::Label`
|
||||
}
|
||||
|
||||
impl VariableLabel {
|
||||
#[doc(alias = "color")]
|
||||
pub fn brush(mut self, brush: impl Into<TextBrush>) -> Self {
|
||||
self.text_brush = brush.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn alignment(mut self, alignment: TextAlignment) -> Self {
|
||||
self.alignment = alignment;
|
||||
self
|
||||
}
|
||||
|
||||
#[doc(alias = "font_size")]
|
||||
pub fn text_size(mut self, text_size: f32) -> Self {
|
||||
self.text_size = text_size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the weight this label will target.
|
||||
///
|
||||
/// If this change is animated, it will occur over `over_millis` milliseconds.
|
||||
///
|
||||
/// Note that updating `over_millis` without changing `weight` will *not* change
|
||||
/// the length of time the weight change occurs over.
|
||||
///
|
||||
/// `over_millis` should be non-negative.
|
||||
/// `weight` should be within the valid range for font weights.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If `weight` is non-finite.
|
||||
pub fn target_weight(mut self, weight: f32, over_millis: f32) -> Self {
|
||||
assert!(weight.is_finite(), "Invalid target weight {weight}.");
|
||||
self.target_weight = Weight::new(weight);
|
||||
self.over_millis = over_millis;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the [font stack](FontStack) this label will use.
|
||||
///
|
||||
/// A font stack allows for providing fallbacks. If there is no matching font
|
||||
/// for a character, a system font will be used (if the system fonts are enabled).
|
||||
///
|
||||
/// This currently requires a `FontStack<'static>`, because it is stored in
|
||||
/// the view, and Parley doesn't support an owned or `Arc` based `FontStack`.
|
||||
/// In most cases, a fontstack value can be static-promoted, but otherwise
|
||||
/// you will currently have to [leak](String::leak) a value and manually keep
|
||||
/// the value.
|
||||
///
|
||||
/// This should be a font stack with variable font support,
|
||||
/// although non-variable fonts will work, just without the smooth animation support.
|
||||
pub fn with_font(mut self, font: FontStack<'static>) -> Self {
|
||||
self.font = font;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewMarker for VariableLabel {}
|
||||
impl<State, Action> View<State, Action, ViewCtx> for VariableLabel {
|
||||
type Element = Pod<widget::VariableLabel>;
|
||||
type ViewState = ();
|
||||
|
||||
fn build(&self, _ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
|
||||
let widget_pod = Pod::new(
|
||||
widget::VariableLabel::new(self.label.clone())
|
||||
.with_text_brush(self.text_brush.clone())
|
||||
.with_line_break_mode(widget::LineBreaking::WordWrap)
|
||||
.with_text_alignment(self.alignment)
|
||||
.with_font(self.font)
|
||||
.with_text_size(self.text_size)
|
||||
.with_initial_weight(self.target_weight.value()),
|
||||
);
|
||||
(widget_pod, ())
|
||||
}
|
||||
|
||||
fn rebuild<'el>(
|
||||
&self,
|
||||
prev: &Self,
|
||||
(): &mut Self::ViewState,
|
||||
ctx: &mut ViewCtx,
|
||||
mut element: Mut<'el, Self::Element>,
|
||||
) -> Mut<'el, Self::Element> {
|
||||
if prev.label != self.label {
|
||||
element.set_text(self.label.clone());
|
||||
ctx.mark_changed();
|
||||
}
|
||||
if prev.text_brush != self.text_brush {
|
||||
element.set_text_brush(self.text_brush.clone());
|
||||
ctx.mark_changed();
|
||||
}
|
||||
if prev.alignment != self.alignment {
|
||||
element.set_alignment(self.alignment);
|
||||
ctx.mark_changed();
|
||||
}
|
||||
if prev.text_size != self.text_size {
|
||||
element.set_text_size(self.text_size);
|
||||
ctx.mark_changed();
|
||||
}
|
||||
if prev.target_weight != self.target_weight {
|
||||
element.set_target_weight(self.target_weight.value(), self.over_millis);
|
||||
ctx.mark_changed();
|
||||
}
|
||||
// First perform a fast filter, then perform a full comparison if that suggests a possible change.
|
||||
let fonts_eq = fonts_eq_fastpath(prev.font, self.font) || prev.font == self.font;
|
||||
if !fonts_eq {
|
||||
element.set_font(self.font);
|
||||
ctx.mark_changed();
|
||||
}
|
||||
element
|
||||
}
|
||||
|
||||
fn teardown(&self, (): &mut Self::ViewState, _: &mut ViewCtx, _: Mut<'_, Self::Element>) {}
|
||||
|
||||
fn message(
|
||||
&self,
|
||||
(): &mut Self::ViewState,
|
||||
_id_path: &[ViewId],
|
||||
message: xilem_core::DynMessage,
|
||||
_app_state: &mut State,
|
||||
) -> crate::MessageResult<Action> {
|
||||
tracing::error!("Message arrived in Label::message, but Label doesn't consume any messages, this is a bug");
|
||||
MessageResult::Stale(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Because all the `FontStack`s we use are 'static, we expect the value to never change.
|
||||
///
|
||||
/// Because of this, we compare the inner pointer value first.
|
||||
/// This function has false negatives, but no false positives.
|
||||
///
|
||||
/// It should be used with a secondary direct comparison using `==`
|
||||
/// if it returns false. If the value does change, this is potentially more expensive.
|
||||
fn fonts_eq_fastpath(lhs: FontStack<'static>, rhs: FontStack<'static>) -> bool {
|
||||
match (lhs, rhs) {
|
||||
(FontStack::Source(lhs), FontStack::Source(rhs)) => {
|
||||
// Slices/strs are properly compared by length
|
||||
core::ptr::eq(lhs.as_ptr(), rhs.as_ptr())
|
||||
}
|
||||
(FontStack::Single(FontFamily::Named(lhs)), FontStack::Single(FontFamily::Named(rhs))) => {
|
||||
core::ptr::eq(lhs.as_ptr(), rhs.as_ptr())
|
||||
}
|
||||
(FontStack::List(lhs), FontStack::List(rhs)) => core::ptr::eq(lhs.as_ptr(), rhs.as_ptr()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue