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:
Daniel McNab 2024-08-16 15:06:42 +01:00 committed by GitHub
parent 052ac39667
commit 3fd3903eae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1112 additions and 14 deletions

View File

@ -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

View File

@ -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]

13
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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.

View File

@ -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

View File

@ -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> {

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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;

View File

@ -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 {}

View File

@ -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;

View File

@ -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 }

View File

@ -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

View File

@ -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!()
}

View File

@ -0,0 +1,9 @@
*
!/.gitignore
!/OFL.txt
!/RobotoFlex-Subset.ttf
!/README.md
# TODO!

View File

@ -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.

View File

@ -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.

View File

@ -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));
}
}
}

View File

@ -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)
}

View File

@ -22,6 +22,9 @@ pub use spinner::*;
mod label;
pub use label::*;
mod variable_label;
pub use variable_label::*;
mod prose;
pub use prose::*;

View File

@ -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,
}
}