diff --git a/.github/workflows/test.sh b/.github/workflows/test.sh index 4e0f4b6c..ec074be8 100644 --- a/.github/workflows/test.sh +++ b/.github/workflows/test.sh @@ -2,8 +2,13 @@ set -ex main() { local cargo=cross + + # all features except those that don't easily work with cross such as font-loading + local all_features="--features std,more-image-formats,image-shrinking,rendering,software-rendering,wasm-web,networking" + if [ "$SKIP_CROSS" = "skip" ]; then cargo=cargo + all_features="--all-features" fi if [ "$TARGET" = "wasm32-wasi" ]; then @@ -13,7 +18,7 @@ main() { return fi - $cargo test -p livesplit-core --all-features --target $TARGET + $cargo test -p livesplit-core $all_features --target $TARGET $cargo test -p livesplit-core --no-default-features --features std --target $TARGET } diff --git a/Cargo.toml b/Cargo.toml index c36018b7..08a4e2bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,9 @@ lyon = { version = "0.16.0", default-features = false, optional = true } rustybuzz = { version = "0.3.0", optional = true } ttf-parser = { version = "0.9.0", optional = true } +# Font Loading +font-kit = { version = "0.10.0", optional = true } + # Software Rendering euc = { version = "0.5.2", default-features = false, features = ["libm"], optional = true } vek = { version = "0.12.1", default-features = false, features = ["libm"], optional = true } @@ -86,6 +89,7 @@ std = ["byteorder", "chrono/std", "chrono/clock", "euc/std", "image", "indexmap" more-image-formats = ["image/webp", "image/pnm", "image/ico", "image/jpeg", "image/tiff", "image/tga", "image/bmp", "image/hdr"] image-shrinking = ["std", "bytemuck", "more-image-formats"] rendering = ["std", "more-image-formats", "euclid", "lyon", "ttf-parser", "rustybuzz", "bytemuck/derive"] +font-loading = ["std", "rendering", "font-kit"] software-rendering = ["rendering", "euc", "vek", "derive_more/mul"] wasm-web = ["std", "web-sys", "chrono/wasmbind", "livesplit-hotkey/wasm-web"] networking = ["std", "splits-io-api"] diff --git a/capi/bind_gen/src/typescript.ts b/capi/bind_gen/src/typescript.ts index 287d0b2f..ed1a9b0d 100644 --- a/capi/bind_gen/src/typescript.ts +++ b/capi/bind_gen/src/typescript.ts @@ -42,6 +42,23 @@ export type Alignment = "Auto" | "Left" | "Center"; export interface LayoutStateJson { /** The state objects for all of the components in the layout. */ components: ComponentStateJson[], + /** The direction which the components are laid out in. */ + direction: LayoutDirection, + /** + * The font to use for the timer text. `null` means a default font should be + * used. + */ + timer_font: Font | null, + /** + * The font to use for the times and other values. `null` means a default + * font should be used. + */ + times_font: Font | null, + /** + * The font to use for regular text. `null` means a default font should be + * used. + */ + text_font: Font | null, /** The background to show behind the layout. */ background: Gradient, /** The color of thin separators. */ @@ -52,6 +69,78 @@ export interface LayoutStateJson { text_color: Color, } +/** + * Describes a Font to visualize text with. Depending on the platform a font + * that matches the settings most closely is chosen. The settings may be ignored + * entirely if the platform can't support different fonts such as in a terminal + * for example. + */ +export interface Font { + /** + * The family name of the font to use. This corresponds with the + * `Typographic Family Name` (Name ID 16) in the name table of the font. If + * no such entry exists, the `Font Family Name` (Name ID 1) is to be used + * instead. If there are multiple entries for the name, the english entry is + * the one to choose. The subfamily is not specified at all and instead a + * suitable subfamily is chosen based on the style, weight and stretch + * values. https://docs.microsoft.com/en-us/typography/opentype/spec/name + * + * This is to ensure the highest portability across various platforms. + * Platforms often select fonts very differently, so if necessary it is also + * fine to store a different font identifier here at the cost of sacrificing + * portability. + */ + family: string, + /** The style of the font to prefer selecting. */ + style: FontStyle, + /** The weight of the font to prefer selecting. */ + weight: FontWeight, + /** The stretch of the font to prefer selecting. */ + stretch: FontStretch, +} + +/** + * The style specifies whether to use a normal or italic version of a font. The + * style may be emulated if no font dedicated to the style can be found. + */ +export type FontStyle = "normal" | "italic"; + +/** + * The weight specifies the weight / boldness of a font. If there is no font + * with the exact weight value, a font with a similar weight is to be chosen + * based on an algorithm similar to this: + * https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Fallback_weights + */ +export type FontWeight = + "thin" | + "extra-light" | + "light" | + "semi-light" | + "normal" | + "medium" | + "semi-bold" | + "bold" | + "extra-bold" | + "black" | + "extra-black"; + +/** + * The stretch specifies how wide a font should be. For example it may make + * sense to reduce the stretch of a font to ensure split names are not cut off. + * A font with a stretch value that is close is to be selected. + * https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch#Font_face_selection + */ +export type FontStretch = + "ultra-condensed" | + "extra-condensed" | + "condensed" | + "semi-condensed" | + "normal" | + "semi-expanded" | + "expanded" | + "extra-expanded" | + "ultra-expanded"; + /** * A Timing Method describes which form of timing is used. This can either be * Real Time or Game Time. @@ -523,6 +612,7 @@ export type SettingsDescriptionValueJson = { ColumnUpdateTrigger: ColumnUpdateTrigger } | { Hotkey: string } | { LayoutDirection: LayoutDirection } | + { Font: Font | null } | { CustomCombobox: CustomCombobox }; /** Describes the direction the components of a layout are laid out in. */ diff --git a/capi/src/parse_run_result.rs b/capi/src/parse_run_result.rs index daa8d747..a02ad009 100644 --- a/capi/src/parse_run_result.rs +++ b/capi/src/parse_run_result.rs @@ -7,8 +7,7 @@ use livesplit_core::run::parser::{ composite::{ParsedRun, Result}, TimerKind, }; -use std::io::Write; -use std::os::raw::c_char; +use std::{io::Write, os::raw::c_char}; /// type pub type ParseRunResult = Result; @@ -47,11 +46,11 @@ pub extern "C" fn ParseRunResult_timer_kind(this: &ParseRunResult) -> *const c_c /// timer format was parsed, instead of one of the more specific timer formats. #[no_mangle] pub extern "C" fn ParseRunResult_is_generic_timer(this: &ParseRunResult) -> bool { - match this { + matches!( + this, Ok(ParsedRun { kind: TimerKind::Generic(_), .. - }) => true, - _ => false, - } + }) + ) } diff --git a/capi/src/setting_value.rs b/capi/src/setting_value.rs index e12142a3..b111a13f 100644 --- a/capi/src/setting_value.rs +++ b/capi/src/setting_value.rs @@ -2,10 +2,16 @@ //! types. use crate::str; -use livesplit_core::component::splits::{ColumnStartWith, ColumnUpdateTrigger, ColumnUpdateWith}; -use livesplit_core::settings::{Alignment, Color, Gradient, ListGradient, Value as SettingValue}; -use livesplit_core::timing::formatter::{Accuracy, DigitsFormat}; -use livesplit_core::{layout::LayoutDirection, TimingMethod}; +use livesplit_core::{ + component::splits::{ColumnStartWith, ColumnUpdateTrigger, ColumnUpdateWith}, + layout::LayoutDirection, + settings::{ + Alignment, Color, Font, FontStretch, FontStyle, FontWeight, Gradient, ListGradient, + Value as SettingValue, + }, + timing::formatter::{Accuracy, DigitsFormat}, + TimingMethod, +}; use std::os::raw::c_char; /// type @@ -295,3 +301,56 @@ pub unsafe extern "C" fn SettingValue_from_layout_direction( }; Some(Box::new(value.into())) } + +/// Creates a new setting value with the type `font`. +#[no_mangle] +pub unsafe extern "C" fn SettingValue_from_font( + family: *const c_char, + style: *const c_char, + weight: *const c_char, + stretch: *const c_char, +) -> NullableOwnedSettingValue { + Some(Box::new( + Some(Font { + family: str(family).to_owned(), + style: match str(style) { + "normal" => FontStyle::Normal, + "italic" => FontStyle::Italic, + _ => return None, + }, + weight: match str(weight) { + "thin" => FontWeight::Thin, + "extra-light" => FontWeight::ExtraLight, + "light" => FontWeight::Light, + "semi-light" => FontWeight::SemiLight, + "normal" => FontWeight::Normal, + "medium" => FontWeight::Medium, + "semi-bold" => FontWeight::SemiBold, + "bold" => FontWeight::Bold, + "extra-bold" => FontWeight::ExtraBold, + "black" => FontWeight::Black, + "extra-black" => FontWeight::ExtraBlack, + _ => return None, + }, + stretch: match str(stretch) { + "ultra-condensed" => FontStretch::UltraCondensed, + "extra-condensed" => FontStretch::ExtraCondensed, + "condensed" => FontStretch::Condensed, + "semi-condensed" => FontStretch::SemiCondensed, + "normal" => FontStretch::Normal, + "semi-expanded" => FontStretch::SemiExpanded, + "expanded" => FontStretch::Expanded, + "extra-expanded" => FontStretch::ExtraExpanded, + "ultra-expanded" => FontStretch::UltraExpanded, + _ => return None, + }, + }) + .into(), + )) +} + +/// Creates a new empty setting value with the type `font`. +#[no_mangle] +pub extern "C" fn SettingValue_from_empty_font() -> OwnedSettingValue { + Box::new(None::.into()) +} diff --git a/capi/src/text_component_state.rs b/capi/src/text_component_state.rs index 42f98565..56f4684a 100644 --- a/capi/src/text_component_state.rs +++ b/capi/src/text_component_state.rs @@ -49,9 +49,5 @@ pub extern "C" fn TextComponentState_center(this: &TextComponentState) -> *const /// Returns whether the text is split up into a left and right part. #[no_mangle] pub extern "C" fn TextComponentState_is_split(this: &TextComponentState) -> bool { - if let TextState::Split(_, _) = this.text { - true - } else { - false - } + matches!(this.text, TextState::Split(_, _)) } diff --git a/crates/livesplit-title-abbreviations/src/lib.rs b/crates/livesplit-title-abbreviations/src/lib.rs index 78b2a9f5..a60ad18e 100644 --- a/crates/livesplit-title-abbreviations/src/lib.rs +++ b/crates/livesplit-title-abbreviations/src/lib.rs @@ -198,12 +198,12 @@ pub fn abbreviate_category(category: &str) -> Vec> { buf.push_str(variable); let old_len = buf.len(); - buf.push_str(")"); + buf.push(')'); buf.push_str(after); abbrevs.push(buf.as_str().into()); buf.drain(old_len..); - buf.push_str(","); + buf.push(','); variable = next_variable; } diff --git a/src/component/graph.rs b/src/component/graph.rs index a48e2c5f..987d5942 100644 --- a/src/component/graph.rs +++ b/src/component/graph.rs @@ -3,10 +3,12 @@ //! the chosen comparison throughout the whole attempt. All the individual //! deltas are shown as points in a graph. -use crate::platform::prelude::*; -use crate::settings::{Color, Field, SettingsDescription, Value}; use crate::{ - analysis, comparison, timing::Snapshot, GeneralLayoutSettings, TimeSpan, Timer, TimerPhase, + analysis, comparison, + platform::prelude::*, + settings::{Color, Field, SettingsDescription, Value}, + timing::Snapshot, + GeneralLayoutSettings, TimeSpan, Timer, TimerPhase, }; use alloc::borrow::Cow; use serde::{Deserialize, Serialize}; @@ -442,7 +444,7 @@ impl Component { let current_time = timer.current_time(); let timing_method = timer.current_timing_method(); draw_info.final_split = current_time[timing_method] - .or_else(|| current_time.real_time) + .or(current_time.real_time) .unwrap_or_else(TimeSpan::zero); } else { let timing_method = timer.current_timing_method(); diff --git a/src/component/splits/column.rs b/src/component/splits/column.rs index dbc58924..8a297b4b 100644 --- a/src/component/splits/column.rs +++ b/src/component/splits/column.rs @@ -1,8 +1,8 @@ -use crate::platform::prelude::*; use crate::{ analysis::{self, possible_time_save, split_color}, clear_vec::Clear, comparison, + platform::prelude::*, settings::{Color, SemanticColor}, timing::{ formatter::{Delta, Regular, SegmentTime, TimeFormatter}, @@ -148,7 +148,7 @@ pub fn update_state( current_split: Option, method: TimingMethod, ) { - let method = column.timing_method.unwrap_or_else(|| method); + let method = column.timing_method.unwrap_or(method); let resolved_comparison = comparison::resolve(&column.comparison_override, timer); let comparison = comparison::or_current(resolved_comparison, timer); @@ -328,16 +328,10 @@ fn column_update_value( impl ColumnUpdateWith { fn is_segment_based(self) -> bool { use self::ColumnUpdateWith::*; - match self { - SegmentDelta | SegmentTime | SegmentDeltaWithFallback => true, - _ => false, - } + matches!(self, SegmentDelta | SegmentTime | SegmentDeltaWithFallback) } fn has_fallback(self) -> bool { use self::ColumnUpdateWith::*; - match self { - DeltaWithFallback | SegmentDeltaWithFallback => true, - _ => false, - } + matches!(self, DeltaWithFallback | SegmentDeltaWithFallback) } } diff --git a/src/component/text/mod.rs b/src/component/text/mod.rs index 8bc7fca0..40817309 100644 --- a/src/component/text/mod.rs +++ b/src/component/text/mod.rs @@ -4,8 +4,8 @@ //! a situation where you have a label and a value. use super::key_value; -use crate::platform::prelude::*; use crate::{ + platform::prelude::*, settings::{Color, Field, Gradient, SettingsDescription, Value}, timing::formatter, Timer, @@ -192,7 +192,7 @@ impl Component { let mut name = String::with_capacity(left.len() + right.len() + 1); name.push_str(left); if !left.is_empty() && !right.is_empty() { - name.push_str(" "); + name.push(' '); } name.push_str(right); name.into() diff --git a/src/layout/general_settings.rs b/src/layout/general_settings.rs index 4af97712..e35dffdd 100644 --- a/src/layout/general_settings.rs +++ b/src/layout/general_settings.rs @@ -1,6 +1,8 @@ use super::LayoutDirection; -use crate::platform::prelude::*; -use crate::settings::{Color, Field, Gradient, SettingsDescription, Value}; +use crate::{ + platform::prelude::*, + settings::{Color, Field, Font, Gradient, SettingsDescription, Value}, +}; use serde::{Deserialize, Serialize}; /// The general settings of the layout that apply to all components. @@ -9,6 +11,15 @@ use serde::{Deserialize, Serialize}; pub struct GeneralSettings { /// The direction which the components are laid out in. pub direction: LayoutDirection, + /// The font to use for the timer text. `None` means a default font should + /// be used. + pub timer_font: Option, + /// The font to use for the times and other values. `None` means a default + /// font should be used. + pub times_font: Option, + /// The font to use for regular text. `None` means a default font should be + /// used. + pub text_font: Option, /// The background to show behind the layout. pub background: Gradient, /// The color to use for when the runner achieved a best segment. @@ -43,6 +54,9 @@ impl Default for GeneralSettings { fn default() -> Self { Self { direction: LayoutDirection::Vertical, + timer_font: None, + times_font: None, + text_font: None, background: Gradient::Plain(Color::hsla(0.0, 0.0, 0.06, 1.0)), best_segment_color: Color::hsla(50.0, 1.0, 0.5, 1.0), ahead_gaining_time_color: Color::hsla(136.0, 1.0, 0.4, 1.0), @@ -65,6 +79,9 @@ impl GeneralSettings { pub fn settings_description(&self) -> SettingsDescription { SettingsDescription::with_fields(vec![ Field::new("Layout Direction".into(), self.direction.into()), + Field::new("Custom Timer Font".into(), self.timer_font.clone().into()), + Field::new("Custom Times Font".into(), self.times_font.clone().into()), + Field::new("Custom Text Font".into(), self.text_font.clone().into()), Field::new("Background".into(), self.background.into()), Field::new("Best Segment".into(), self.best_segment_color.into()), Field::new( @@ -102,18 +119,21 @@ impl GeneralSettings { pub fn set_value(&mut self, index: usize, value: Value) { match index { 0 => self.direction = value.into(), - 1 => self.background = value.into(), - 2 => self.best_segment_color = value.into(), - 3 => self.ahead_gaining_time_color = value.into(), - 4 => self.ahead_losing_time_color = value.into(), - 5 => self.behind_gaining_time_color = value.into(), - 6 => self.behind_losing_time_color = value.into(), - 7 => self.not_running_color = value.into(), - 8 => self.personal_best_color = value.into(), - 9 => self.paused_color = value.into(), - 10 => self.thin_separators_color = value.into(), - 11 => self.separators_color = value.into(), - 12 => self.text_color = value.into(), + 1 => self.timer_font = value.into(), + 2 => self.times_font = value.into(), + 3 => self.text_font = value.into(), + 4 => self.background = value.into(), + 5 => self.best_segment_color = value.into(), + 6 => self.ahead_gaining_time_color = value.into(), + 7 => self.ahead_losing_time_color = value.into(), + 8 => self.behind_gaining_time_color = value.into(), + 9 => self.behind_losing_time_color = value.into(), + 10 => self.not_running_color = value.into(), + 11 => self.personal_best_color = value.into(), + 12 => self.paused_color = value.into(), + 13 => self.thin_separators_color = value.into(), + 14 => self.separators_color = value.into(), + 15 => self.text_color = value.into(), _ => panic!("Unsupported Setting Index"), } } diff --git a/src/layout/layout_state.rs b/src/layout/layout_state.rs index 80d7aa13..28f55246 100644 --- a/src/layout/layout_state.rs +++ b/src/layout/layout_state.rs @@ -1,6 +1,8 @@ use super::{ComponentState, LayoutDirection}; -use crate::platform::prelude::*; -use crate::settings::{Color, Gradient}; +use crate::{ + platform::prelude::*, + settings::{Color, Font, Gradient}, +}; use serde::{Deserialize, Serialize}; /// The state object describes the information to visualize for the layout. @@ -10,6 +12,15 @@ pub struct LayoutState { pub components: Vec, /// The direction which the components are laid out in. pub direction: LayoutDirection, + /// The font to use for the timer text. `None` means a default font should + /// be used. + pub timer_font: Option, + /// The font to use for the times and other values. `None` means a default + /// font should be used. + pub times_font: Option, + /// The font to use for regular text. `None` means a default font should be + /// used. + pub text_font: Option, /// The background to show behind the layout. pub background: Gradient, /// The color of thin separators. diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 8dbe1569..f3341800 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -13,18 +13,17 @@ mod layout_state; #[cfg(feature = "std")] pub mod parser; -pub use self::component::Component; -pub use self::component_settings::ComponentSettings; -pub use self::component_state::ComponentState; -pub use self::editor::Editor; -pub use self::general_settings::GeneralSettings; -pub use self::layout_direction::LayoutDirection; -pub use self::layout_settings::LayoutSettings; -pub use self::layout_state::LayoutState; - -use crate::component::{previous_segment, splits, timer, title}; -use crate::platform::prelude::*; -use crate::timing::Snapshot; +pub use self::{ + component::Component, component_settings::ComponentSettings, component_state::ComponentState, + editor::Editor, general_settings::GeneralSettings, layout_direction::LayoutDirection, + layout_settings::LayoutSettings, layout_state::LayoutState, +}; + +use crate::{ + component::{previous_segment, splits, timer, title}, + platform::prelude::*, + timing::Snapshot, +}; /// A Layout allows you to combine multiple components together to visualize a /// variety of information the runner is interested in. @@ -102,6 +101,10 @@ impl Layout { .components .extend(components.map(|c| c.state(timer, settings))); + state.timer_font.clone_from(&settings.timer_font); + state.times_font.clone_from(&settings.times_font); + state.text_font.clone_from(&settings.text_font); + state.background = settings.background; state.thin_separators_color = settings.thin_separators_color; state.separators_color = settings.separators_color; diff --git a/src/layout/parser.rs b/src/layout/parser.rs index 700e6d5b..519bacdd 100644 --- a/src/layout/parser.rs +++ b/src/layout/parser.rs @@ -3,18 +3,20 @@ use super::{Component, Layout, LayoutDirection}; use crate::{ component::separator, - settings::{Alignment, Color, Gradient, ListGradient}, + settings::{ + Alignment, Color, Font, FontStretch, FontStyle, FontWeight, Gradient, ListGradient, + }, timing::{ formatter::{Accuracy, DigitsFormat}, TimingMethod, }, xml_util::{ - end_tag, parse_base, parse_children, text, text_as_escaped_bytes_err, text_err, - text_parsed, Error as XmlError, Tag, + end_tag, parse_base, parse_children, text, text_as_bytes_err, text_as_escaped_bytes_err, + text_err, text_parsed, Error as XmlError, Tag, }, }; use quick_xml::Reader; -use std::io::BufRead; +use std::{io::BufRead, str}; mod blank_space; mod current_comparison; @@ -32,6 +34,16 @@ mod timer; mod title; mod total_playtime; +// One single row component is: +// 1.0 units high in component space. +// 24 pixels high in LiveSplit One's pixel coordinate space. +// ~30.5 pixels high in the original LiveSplit's pixel coordinate space. +const PIXEL_SPACE_RATIO: f32 = 24.0 / 30.5; + +fn translate_size(v: u32) -> u32 { + (v as f32 * PIXEL_SPACE_RATIO).round() as u32 +} + /// The Error type for parsing layout files of the original LiveSplit. #[derive(Debug, snafu::Snafu, derive_more::From)] pub enum Error { @@ -71,6 +83,8 @@ pub enum Error { ParseAlignment, /// Failed to parse a column type. ParseColumnType, + /// Failed to parse a font. + ParseFont, /// Parsed an empty layout, which is considered an invalid layout. Empty, } @@ -103,10 +117,13 @@ impl GradientType for GradientKind { GradientKind::Transparent } fn parse(kind: &[u8]) -> Result { + // FIXME: Implement delta color support properly: + // https://github.com/LiveSplit/livesplit-core/issues/380 + Ok(match kind { - b"Plain" => GradientKind::Plain, - b"Vertical" => GradientKind::Vertical, - b"Horizontal" => GradientKind::Horizontal, + b"Plain" | b"PlainWithDeltaColor" => GradientKind::Plain, + b"Vertical" | b"VerticalWithDeltaColor" => GradientKind::Vertical, + b"Horizontal" | b"HorizontalWithDeltaColor" => GradientKind::Horizontal, _ => return Err(Error::ParseGradientType), }) } @@ -242,6 +259,171 @@ where }) } +fn font( + reader: &mut Reader, + result: &mut Vec, + font_buf: &mut Vec, + f: F, +) -> Result<()> +where + R: BufRead, + F: FnOnce(Font), +{ + text_as_bytes_err(reader, result, |text| { + // The format for this is documented here: + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-nrbf/75b9fe09-be15-475f-85b8-ae7b7558cfe5 + // + // The structure follows roughly: + // + // class System.Drawing.Font { + // String Name; + // float Size; + // System.Drawing.FontStyle Style; + // System.Drawing.GraphicsUnit Unit; + // } + // + // The full definition can be found here: + // https://referencesource.microsoft.com/#System.Drawing/commonui/System/Drawing/Advanced/Font.cs,130 + + let rem = text.get(304..).ok_or(Error::ParseFont)?; + font_buf.clear(); + base64::decode_config_buf(rem, base64::STANDARD, font_buf).map_err(|_| Error::ParseFont)?; + + let mut cursor = font_buf.get(1..).ok_or(Error::ParseFont)?.iter(); + + // Strings are encoded as varint for the length + the UTF-8 string data. + let mut len = 0; + for _ in 0..5 { + let byte = *cursor.next().ok_or(Error::ParseFont)?; + len = len << 7 | (byte & 0b0111_1111) as usize; + if byte <= 0b0111_1111 { + break; + } + } + let rem = cursor.as_slice(); + + let font_name = rem.get(..len).ok_or(Error::ParseFont)?; + let mut family = str::from_utf8(font_name) + .map_err(|_| Error::ParseFont)? + .trim(); + + let mut style = FontStyle::Normal; + let mut weight = FontWeight::Normal; + let mut stretch = FontStretch::Normal; + + // The original LiveSplit is based on Windows Forms, which is just a + // .NET wrapper around GDI+. It's a pretty old API from before + // DirectWrite existed, and fonts used to be very different back then. + // This is why GDI uses a very different identifier for fonts than + // modern APIs. Since all the modern APIs take a font family, we somehow + // need to convert the font identifier from the original LiveSplit into + // a font family. The problem is that we may not necessarily even have + // the font, nor be on a platform where we could even query for any + // fonts or get enough metadata about them, such as in the browser. So + // for those cases, we implement a very simple, though also really lossy + // algorithm that simply splits away common tokens at the end that refer + // to the subfamily / styling of the font. In most cases this should + // yield the font family that we are looking for and the additional + // styling information. Another problem with this approach is that GDI + // limits its font identifiers to 32 characters, so the tokens that we + // may want to split off, may themselves already be cut off, causing us + // to not recognize them. An example of this is "Bahnschrift SemiLight + // SemiConde" where the end should say "SemiCondensed" but doesn't due + // to the character limit. + // + // A more sophisticated approach where on Windows we may talk directly + // to GDI to resolve the name has not been implemented so far. The + // problem is that GDI does not give you access to either the path of + // the font or its data. You can receive the byte representation of + // individual tables you query for, but ttf-parser, the crate we use for + // parsing fonts, doesn't expose the ability to parse individual tables + // in its public API. + + for token in family.split_whitespace().rev() { + if token.eq_ignore_ascii_case("italic") { + style = FontStyle::Italic; + } else if token.eq_ignore_ascii_case("thin") || token.eq_ignore_ascii_case("hairline") { + weight = FontWeight::Thin; + } else if token.eq_ignore_ascii_case("extralight") + || token.eq_ignore_ascii_case("ultralight") + { + weight = FontWeight::ExtraLight; + } else if token.eq_ignore_ascii_case("light") { + weight = FontWeight::Light; + } else if token.eq_ignore_ascii_case("semilight") + || token.eq_ignore_ascii_case("demilight") + { + weight = FontWeight::SemiLight; + } else if token.eq_ignore_ascii_case("normal") { + weight = FontWeight::Normal; + } else if token.eq_ignore_ascii_case("medium") { + weight = FontWeight::Medium; + } else if token.eq_ignore_ascii_case("semibold") + || token.eq_ignore_ascii_case("demibold") + { + weight = FontWeight::SemiBold; + } else if token.eq_ignore_ascii_case("bold") { + weight = FontWeight::Bold; + } else if token.eq_ignore_ascii_case("extrabold") + || token.eq_ignore_ascii_case("ultrabold") + { + weight = FontWeight::ExtraBold; + } else if token.eq_ignore_ascii_case("black") || token.eq_ignore_ascii_case("heavy") { + weight = FontWeight::Black; + } else if token.eq_ignore_ascii_case("extrablack") + || token.eq_ignore_ascii_case("ultrablack") + { + weight = FontWeight::ExtraBlack; + } else if token.eq_ignore_ascii_case("ultracondensed") { + stretch = FontStretch::UltraCondensed; + } else if token.eq_ignore_ascii_case("extracondensed") { + stretch = FontStretch::ExtraCondensed; + } else if token.eq_ignore_ascii_case("condensed") { + stretch = FontStretch::Condensed; + } else if token.eq_ignore_ascii_case("semicondensed") { + stretch = FontStretch::SemiCondensed; + } else if token.eq_ignore_ascii_case("normal") { + stretch = FontStretch::Normal; + } else if token.eq_ignore_ascii_case("semiexpanded") { + stretch = FontStretch::SemiExpanded; + } else if token.eq_ignore_ascii_case("expanded") { + stretch = FontStretch::Expanded; + } else if token.eq_ignore_ascii_case("extraexpanded") { + stretch = FontStretch::ExtraExpanded; + } else if token.eq_ignore_ascii_case("ultraexpanded") { + stretch = FontStretch::UltraExpanded; + } else if !token.eq_ignore_ascii_case("regular") { + family = + &family[..token.as_ptr() as usize - family.as_ptr() as usize + token.len()]; + break; + } + } + + // Later on we find the style as bitflags of System.Drawing.FontStyle. + // 1 -> bold + // 2 -> italic + // 4 -> underline + // 8 -> strikeout + let flags = *rem.get(len + 52).ok_or(Error::ParseFont)?; + + if flags & 1 != 0 { + weight = FontWeight::Bold; + } + + if flags & 2 != 0 { + style = FontStyle::Italic; + } + + f(Font { + family: family.to_owned(), + style, + weight, + stretch, + }); + Ok(()) + }) +} + fn parse_bool(reader: &mut Reader, buf: &mut Vec, f: F) -> Result<()> where R: BufRead, @@ -424,6 +606,8 @@ fn parse_general_settings( let settings = layout.general_settings_mut(); let mut background_builder = GradientBuilder::new(); + let mut font_buf = Vec::new(); + parse_children(reader, buf, |reader, tag| { if tag.name() == b"TextColor" { color(reader, tag.into_buf(), |color| { @@ -477,6 +661,24 @@ fn parse_general_settings( color(reader, tag.into_buf(), |color| { settings.paused_color = color; }) + } else if tag.name() == b"TimerFont" { + font(reader, tag.into_buf(), &mut font_buf, |font| { + if font.family != "Calibri" && font.family != "Century Gothic" { + settings.timer_font = Some(font); + } + }) + } else if tag.name() == b"TimesFont" { + font(reader, tag.into_buf(), &mut font_buf, |font| { + if font.family != "Segoe UI" { + settings.times_font = Some(font); + } + }) + } else if tag.name() == b"TextFont" { + font(reader, tag.into_buf(), &mut font_buf, |font| { + if font.family != "Segoe UI" { + settings.text_font = Some(font); + } + }) } else if tag.name() == b"BackgroundType" { text_err(reader, tag.into_buf(), |text| { background_builder.kind = match &*text { diff --git a/src/layout/parser/blank_space.rs b/src/layout/parser/blank_space.rs index 30d1ae98..936904dc 100644 --- a/src/layout/parser/blank_space.rs +++ b/src/layout/parser/blank_space.rs @@ -1,4 +1,4 @@ -use super::{Error, GradientBuilder, Result}; +use super::{translate_size, Error, GradientBuilder, Result}; use crate::xml_util::{end_tag, parse_children, text_parsed}; use quick_xml::Reader; use std::io::BufRead; @@ -19,7 +19,9 @@ where parse_children::<_, _, Error>(reader, buf, |reader, tag| { if let Some(tag) = background_builder.parse_background(reader, tag)? { if tag.name() == b"SpaceHeight" { - text_parsed(reader, tag.into_buf(), |h| settings.size = h) + text_parsed(reader, tag.into_buf(), |h| { + settings.size = translate_size(h) + }) } else { // FIXME: // SpaceWidth diff --git a/src/layout/parser/detailed_timer.rs b/src/layout/parser/detailed_timer.rs index ea25a77f..35de865d 100644 --- a/src/layout/parser/detailed_timer.rs +++ b/src/layout/parser/detailed_timer.rs @@ -1,6 +1,6 @@ use super::{ accuracy, color, comparison_override, end_tag, parse_bool, parse_children, text_parsed, - timer_format, timing_method_override, GradientBuilder, Result, + timer_format, timing_method_override, translate_size, GradientBuilder, Result, }; use crate::timing::formatter::DigitsFormat; use quick_xml::Reader; @@ -24,7 +24,7 @@ where parse_children(reader, buf, |reader, tag| { if let Some(tag) = background_builder.parse_background(reader, tag)? { if tag.name() == b"Height" { - text_parsed(reader, tag.into_buf(), |v| total_height = v) + text_parsed(reader, tag.into_buf(), |v| total_height = translate_size(v)) } else if tag.name() == b"SegmentTimerSizeRatio" { text_parsed(reader, tag.into_buf(), |v: u32| { segment_timer_ratio = v as f32 / 100.0 diff --git a/src/layout/parser/graph.rs b/src/layout/parser/graph.rs index 7a3fc4a5..9c769ffb 100644 --- a/src/layout/parser/graph.rs +++ b/src/layout/parser/graph.rs @@ -1,4 +1,7 @@ -use super::{color, comparison_override, end_tag, parse_bool, parse_children, text_parsed, Result}; +use super::{ + color, comparison_override, end_tag, parse_bool, parse_children, text_parsed, translate_size, + Result, +}; use quick_xml::Reader; use std::io::BufRead; @@ -16,7 +19,9 @@ where parse_children(reader, buf, |reader, tag| { if tag.name() == b"Height" { - text_parsed(reader, tag.into_buf(), |v| settings.height = v) + text_parsed(reader, tag.into_buf(), |v| { + settings.height = translate_size(v) + }) } else if tag.name() == b"BehindGraphColor" { color(reader, tag.into_buf(), |c| { settings.behind_background_color = c diff --git a/src/layout/parser/timer.rs b/src/layout/parser/timer.rs index 6ae34254..5ebfb885 100644 --- a/src/layout/parser/timer.rs +++ b/src/layout/parser/timer.rs @@ -1,6 +1,6 @@ use super::{ accuracy, color, end_tag, parse_bool, parse_children, text_parsed, timer_format, - timing_method_override, GradientBuilder, Result, + timing_method_override, translate_size, GradientBuilder, Result, }; use quick_xml::Reader; use std::io::BufRead; @@ -22,7 +22,9 @@ where parse_children(reader, buf, |reader, tag| { if let Some(tag) = background_builder.parse_background(reader, tag)? { if tag.name() == b"TimerHeight" { - text_parsed(reader, tag.into_buf(), |v| settings.height = v) + text_parsed(reader, tag.into_buf(), |v| { + settings.height = translate_size(v) + }) } else if tag.name() == b"TimerFormat" { // Version >= 1.5 timer_format(reader, tag.into_buf(), |d, a| { diff --git a/src/rendering/component/title.rs b/src/rendering/component/title.rs index 733f6f11..37f6a143 100644 --- a/src/rendering/component/title.rs +++ b/src/rendering/component/title.rs @@ -49,18 +49,18 @@ pub(in crate::rendering) fn render( [width - PADDING, height + TEXT_ALIGN_BOTTOM], DEFAULT_TEXT_SIZE, [text_color; 2], - ); + ) - PADDING; let (line1_y, line1_end_x) = if !component.line2.is_empty() { let line2 = context.choose_abbreviation( component.line2.iter().map(|a| &**a), DEFAULT_TEXT_SIZE, - line2_end_x - PADDING - left_bound, + line2_end_x - left_bound, ); context.render_text_align( line2, left_bound, - line2_end_x - PADDING, + line2_end_x, [line_x, height + TEXT_ALIGN_BOTTOM], DEFAULT_TEXT_SIZE, component.is_centered, @@ -68,13 +68,13 @@ pub(in crate::rendering) fn render( ); (TEXT_ALIGN_TOP, width - PADDING) } else { - (height / 2.0 + TEXT_ALIGN_CENTER, line2_end_x - PADDING) + (height / 2.0 + TEXT_ALIGN_CENTER, line2_end_x) }; let line1 = context.choose_abbreviation( component.line1.iter().map(|a| &**a), DEFAULT_TEXT_SIZE, - width - PADDING - left_bound, + line1_end_x - left_bound, ); context.render_text_align( diff --git a/src/rendering/font.rs b/src/rendering/font.rs index dbcb16ce..5da16d58 100644 --- a/src/rendering/font.rs +++ b/src/rendering/font.rs @@ -1,21 +1,104 @@ use super::{decode_color, glyph_cache::GlyphCache, Backend, Pos, Transform}; -use crate::settings::Color; -use rustybuzz::{Face, Feature, GlyphBuffer, Tag, UnicodeBuffer}; +use crate::settings::{Color, FontStretch, FontStyle, FontWeight}; +#[cfg(feature = "font-loading")] +use font_kit::properties::{Stretch, Style, Weight}; +use rustybuzz::{Face, Feature, GlyphBuffer, Tag, UnicodeBuffer, Variation}; use ttf_parser::{GlyphId, OutlineBuilder}; +#[cfg(feature = "font-loading")] +use { + font_kit::{ + family_name::FamilyName, handle::Handle, properties::Properties, source::SystemSource, + }, + std::{fs, sync::Arc}, +}; + pub struct Font<'fd> { rb: Face<'fd>, face: ttf_parser::Face<'fd>, scale_factor: f32, + #[cfg(feature = "font-loading")] + /// Safety: This can never be mutated. This also needs to be dropped last. + buf: Option>, } impl<'fd> Font<'fd> { - pub fn from_slice(data: &'fd [u8], index: u32) -> Option { - let parser = ttf_parser::Face::from_slice(data, index).ok()?; + #[cfg(feature = "font-loading")] + pub fn load(font: &crate::settings::Font) -> Option> { + let handle = SystemSource::new() + .select_best_match( + &[FamilyName::Title(font.family.clone())], + &Properties { + style: match font.style { + FontStyle::Normal => Style::Normal, + FontStyle::Italic => Style::Italic, + }, + weight: Weight(font.weight.value()), + stretch: Stretch(font.stretch.factor()), + }, + ) + .ok()?; + + let (buf, font_index) = match handle { + Handle::Path { path, font_index } => (fs::read(path).ok()?, font_index), + Handle::Memory { bytes, font_index } => ( + Arc::try_unwrap(bytes).unwrap_or_else(|bytes| (*bytes).clone()), + font_index, + ), + }; + let buf = buf.into_boxed_slice(); + + // Safety: We store our own buffer. If we never modify it and drop it + // last, this is fine. It also needs to be heap allocated, so it's a + // stable pointer. This is guaranteed by the boxed slice. + unsafe { + let slice: *const [u8] = &*buf; + let mut font = + Font::from_slice(&*slice, font_index, font.style, font.weight, font.stretch)?; + font.buf = Some(buf); + Some(font) + } + } + + pub fn from_slice( + data: &'fd [u8], + index: u32, + style: FontStyle, + weight: FontWeight, + stretch: FontStretch, + ) -> Option { + let mut parser = ttf_parser::Face::from_slice(data, index).ok()?; + let mut rb = Face::from_slice(data, index)?; + + let italic = style.value_for_italic(); + let weight = weight.value(); + let stretch = stretch.percentage(); + + parser.set_variation(Tag::from_bytes(b"ital"), italic); + parser.set_variation(Tag::from_bytes(b"wght"), weight); + parser.set_variation(Tag::from_bytes(b"wdth"), stretch); + + rb.set_variations(&[ + Variation { + tag: Tag::from_bytes(b"ital"), + value: italic, + }, + Variation { + tag: Tag::from_bytes(b"wght"), + value: weight, + }, + Variation { + tag: Tag::from_bytes(b"wdth"), + value: stretch, + }, + ]); + Some(Self { scale_factor: 1.0 / parser.height() as f32, - rb: Face::from_slice(data, index)?, + rb, face: parser, + #[cfg(feature = "font-loading")] + buf: None, }) } @@ -202,9 +285,24 @@ impl<'f> Glyphs<'f> { adv_y += p.y_advance; } - cursor.x -= adv_x as f32 * self.font.scale / 2.0; + let width = adv_x as f32 * self.font.scale; + + // Since we want to delegate to left aligned, we calculate the left + // coordinates. + cursor.x -= width / 2.0; cursor.y -= adv_y as f32 * self.font.scale / 2.0; + // However, we may overlap on the right. In that case, we want to align + // to the right instead. + if cursor.x + width >= max_x { + // Small epsilon, because we still call the left aligned function. + // Due to floating point precision issues this may be considered too + // far to the right and may cause the text to have ellipsis. + cursor.x -= cursor.x + width - max_x + (5.0 * std::f32::EPSILON); + } + + // However if we are too far to the left, we align it to the minimum + // left position. if cursor.x < min_x { cursor.x = min_x; } diff --git a/src/rendering/glyph_cache.rs b/src/rendering/glyph_cache.rs index 8acd0ab7..61df1433 100644 --- a/src/rendering/glyph_cache.rs +++ b/src/rendering/glyph_cache.rs @@ -48,6 +48,13 @@ impl GlyphCache { Default::default() } + #[cfg(feature = "font-loading")] + pub fn clear(&mut self, backend: &mut impl Backend) { + for (_, mesh) in self.glyphs.drain() { + backend.free_mesh(mesh); + } + } + pub fn lookup_or_insert( &mut self, font: &Font<'_>, diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs index e5b5a184..3b783f6d 100644 --- a/src/rendering/mod.rs +++ b/src/rendering/mod.rs @@ -75,7 +75,7 @@ pub mod software; use self::{font::Font, glyph_cache::GlyphCache, icon::Icon}; use crate::{ layout::{ComponentState, LayoutDirection, LayoutState}, - settings::{Color, Gradient}, + settings::{Color, FontStretch, FontStyle, FontWeight, Gradient}, }; use alloc::borrow::Cow; use core::iter; @@ -182,10 +182,18 @@ enum CachedSize { /// A renderer can be used to render out layout states with the backend chosen. pub struct Renderer { - text_font: Font<'static>, - text_glyph_cache: GlyphCache, + #[cfg(feature = "font-loading")] + timer_font_setting: Option, timer_font: Font<'static>, timer_glyph_cache: GlyphCache, + #[cfg(feature = "font-loading")] + times_font_setting: Option, + times_font: Font<'static>, + times_glyph_cache: GlyphCache, + #[cfg(feature = "font-loading")] + text_font_setting: Option, + text_font: Font<'static>, + text_glyph_cache: GlyphCache, rectangle: Option, cached_size: Option, icons: IconCache, @@ -208,9 +216,38 @@ impl Renderer { /// Creates a new renderer. pub fn new() -> Self { Self { - timer_font: Font::from_slice(TIMER_FONT, 0).unwrap(), + #[cfg(feature = "font-loading")] + timer_font_setting: None, + timer_font: Font::from_slice( + TIMER_FONT, + 0, + FontStyle::Normal, + FontWeight::Bold, + FontStretch::Normal, + ) + .unwrap(), timer_glyph_cache: GlyphCache::new(), - text_font: Font::from_slice(TEXT_FONT, 0).unwrap(), + #[cfg(feature = "font-loading")] + times_font_setting: None, + times_font: Font::from_slice( + TEXT_FONT, + 0, + FontStyle::Normal, + FontWeight::Bold, + FontStretch::Normal, + ) + .unwrap(), + times_glyph_cache: GlyphCache::new(), + #[cfg(feature = "font-loading")] + text_font_setting: None, + text_font: Font::from_slice( + TEXT_FONT, + 0, + FontStyle::Normal, + FontWeight::Normal, + FontStretch::Normal, + ) + .unwrap(), text_glyph_cache: GlyphCache::new(), rectangle: None, icons: IconCache { @@ -232,6 +269,66 @@ impl Renderer { resolution: (f32, f32), state: &LayoutState, ) { + #[cfg(feature = "font-loading")] + { + if self.timer_font_setting != state.timer_font { + self.timer_font = state + .timer_font + .as_ref() + .and_then(Font::load) + .unwrap_or_else(|| { + Font::from_slice( + TIMER_FONT, + 0, + FontStyle::Normal, + FontWeight::Bold, + FontStretch::Normal, + ) + .unwrap() + }); + self.timer_glyph_cache.clear(backend); + self.timer_font_setting.clone_from(&state.timer_font); + } + + if self.times_font_setting != state.times_font { + self.times_font = state + .times_font + .as_ref() + .and_then(Font::load) + .unwrap_or_else(|| { + Font::from_slice( + TEXT_FONT, + 0, + FontStyle::Normal, + FontWeight::Bold, + FontStretch::Normal, + ) + .unwrap() + }); + self.times_glyph_cache.clear(backend); + self.times_font_setting.clone_from(&state.times_font); + } + + if self.text_font_setting != state.text_font { + self.text_font = state + .text_font + .as_ref() + .and_then(Font::load) + .unwrap_or_else(|| { + Font::from_slice( + TEXT_FONT, + 0, + FontStyle::Normal, + FontWeight::Normal, + FontStretch::Normal, + ) + .unwrap() + }); + self.text_glyph_cache.clear(backend); + self.text_font_setting.clone_from(&state.text_font); + } + } + match state.direction { LayoutDirection::Vertical => self.render_vertical(backend, resolution, state), LayoutDirection::Horizontal => self.render_horizontal(backend, resolution, state), @@ -276,6 +373,8 @@ impl Renderer { rectangle: &mut self.rectangle, timer_font: &self.timer_font, timer_glyph_cache: &mut self.timer_glyph_cache, + times_font: &mut self.times_font, + times_glyph_cache: &mut self.times_glyph_cache, text_font: &self.text_font, text_glyph_cache: &mut self.text_glyph_cache, text_buffer: &mut self.text_buffer, @@ -348,6 +447,8 @@ impl Renderer { rectangle: &mut self.rectangle, timer_font: &mut self.timer_font, timer_glyph_cache: &mut self.timer_glyph_cache, + times_font: &mut self.times_font, + times_glyph_cache: &mut self.times_glyph_cache, text_font: &mut self.text_font, text_glyph_cache: &mut self.text_glyph_cache, text_buffer: &mut self.text_buffer, @@ -434,6 +535,8 @@ struct RenderContext<'b, B: Backend> { timer_glyph_cache: &'b mut GlyphCache, text_font: &'b Font<'static>, text_glyph_cache: &'b mut GlyphCache, + times_font: &'b Font<'static>, + times_glyph_cache: &'b mut GlyphCache, text_buffer: &'b mut Option, } @@ -688,14 +791,14 @@ impl RenderContext<'_, B> { buffer.push_str(text.trim()); buffer.guess_segment_properties(); - let font = self.text_font.scale(scale); + let font = self.times_font.scale(scale); let glyphs = font.shape_tabular_numbers(buffer); font::render( glyphs.tabular_numbers(&mut cursor), colors, &font, - self.text_glyph_cache, + self.times_glyph_cache, &self.transform, self.backend, ); @@ -784,7 +887,7 @@ impl RenderContext<'_, B> { buffer.push_str(text.trim()); buffer.guess_segment_properties(); - let glyphs = self.text_font.scale(scale).shape_tabular_numbers(buffer); + let glyphs = self.times_font.scale(scale).shape_tabular_numbers(buffer); // Iterate over all glyphs, to move the cursor forward. glyphs.tabular_numbers(&mut cursor).for_each(drop); diff --git a/src/settings/font.rs b/src/settings/font.rs new file mode 100644 index 00000000..69ec8535 --- /dev/null +++ b/src/settings/font.rs @@ -0,0 +1,160 @@ +use crate::platform::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Describes a Font to visualize text with. Depending on the platform, a font +/// that matches the settings most closely is chosen. The settings may be +/// ignored entirely if the platform can't support different fonts, such as in a +/// terminal. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Font { + /// The family name of the font to use. This corresponds with the + /// `Typographic Family Name` (Name ID 16) in the name table of the font. If + /// no such entry exists, the `Font Family Name` (Name ID 1) is to be used + /// instead. If there are multiple entries for the name, the english entry + /// is the one to choose. The subfamily is not specified at all, and instead + /// a suitable subfamily is chosen based on the style, weight and stretch + /// values. + /// https://docs.microsoft.com/en-us/typography/opentype/spec/name + /// + /// This is to ensure the highest portability across various platforms. + /// Platforms often select fonts very differently, so if necessary it is + /// also fine to store a different font identifier here at the cost of + /// sacrificing portability. + pub family: String, + /// The style of the font to prefer selecting. + pub style: Style, + /// The weight of the font to prefer selecting. + pub weight: Weight, + /// The stretch of the font to prefer selecting. + pub stretch: Stretch, +} + +/// The style specifies whether to use a normal or italic version of a font. The +/// style may be emulated if no font dedicated to the style can be found. +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum Style { + /// Select a regular, non-italic version of the font. + Normal, + /// Select an italic version of the font. + Italic, +} + +impl Style { + /// The value to assign to the `ital` variation axis. + pub fn value_for_italic(self) -> f32 { + match self { + Style::Normal => 0.0, + Style::Italic => 1.0, + } + } +} + +/// The weight specifies the weight / boldness of a font. If there is no font +/// with the exact weight value, a font with a similar weight is to be chosen +/// based on an algorithm similar to this: +/// https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Fallback_weights +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum Weight { + /// 100 (also known as Hairline) + Thin, + /// 200 (also known as Ultra Light) + ExtraLight, + /// 300 + Light, + /// 350 (also known as Demi Light) + SemiLight, + /// 400 (also known as Regular) + Normal, + /// 500 + Medium, + /// 600 (also known as Demi Bold) + SemiBold, + /// 700 + Bold, + /// 800 (also known as Ultra Bold) + ExtraBold, + /// 900 (also known as Heavy) + Black, + /// 950 (also known as Ultra Black) + ExtraBlack, +} + +impl Weight { + /// The numeric value of the weight. + pub fn value(self) -> f32 { + match self { + Weight::Thin => 100.0, + Weight::ExtraLight => 200.0, + Weight::Light => 300.0, + Weight::SemiLight => 350.0, + Weight::Normal => 400.0, + Weight::Medium => 500.0, + Weight::SemiBold => 600.0, + Weight::Bold => 700.0, + Weight::ExtraBold => 800.0, + Weight::Black => 900.0, + Weight::ExtraBlack => 950.0, + } + } +} + +/// The stretch specifies how wide a font should be. For example, it may make +/// sense to reduce the stretch of a font to ensure split names are not cut off. +/// A font with a stretch value that is close is to be selected. +/// https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch#Font_face_selection +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum Stretch { + /// 50% + UltraCondensed, + /// 62.5% + ExtraCondensed, + /// 75% + Condensed, + /// 87.5% + SemiCondensed, + /// 100% + Normal, + /// 112.5% + SemiExpanded, + /// 125% + Expanded, + /// 150% + ExtraExpanded, + /// 200% + UltraExpanded, +} + +impl Stretch { + /// The percentage the font is stretched by (50% to 200%). + pub fn percentage(self) -> f32 { + match self { + Stretch::UltraCondensed => 50.0, + Stretch::ExtraCondensed => 62.5, + Stretch::Condensed => 75.0, + Stretch::SemiCondensed => 87.5, + Stretch::Normal => 100.0, + Stretch::SemiExpanded => 112.5, + Stretch::Expanded => 125.0, + Stretch::ExtraExpanded => 150.0, + Stretch::UltraExpanded => 200.0, + } + } + + /// The factor the font is stretched by (0x to 2x). + pub fn factor(self) -> f32 { + match self { + Stretch::UltraCondensed => 0.5, + Stretch::ExtraCondensed => 0.625, + Stretch::Condensed => 0.75, + Stretch::SemiCondensed => 0.875, + Stretch::Normal => 1.0, + Stretch::SemiExpanded => 1.125, + Stretch::Expanded => 1.25, + Stretch::ExtraExpanded => 1.5, + Stretch::UltraExpanded => 2.0, + } + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 9a9ff796..a43b9387 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -4,17 +4,21 @@ mod alignment; mod color; mod field; +mod font; mod gradient; mod image; mod semantic_color; mod settings_description; mod value; -pub use self::alignment::Alignment; -pub use self::color::Color; -pub use self::field::Field; -pub use self::gradient::{Gradient, ListGradient}; -pub use self::image::{CachedImageId, Image, ImageData}; -pub use self::semantic_color::SemanticColor; -pub use self::settings_description::SettingsDescription; -pub use self::value::{Error as ValueError, Result as ValueResult, Value}; +pub use self::{ + alignment::Alignment, + color::Color, + field::Field, + font::{Font, Stretch as FontStretch, Style as FontStyle, Weight as FontWeight}, + gradient::{Gradient, ListGradient}, + image::{CachedImageId, Image, ImageData}, + semantic_color::SemanticColor, + settings_description::SettingsDescription, + value::{Error as ValueError, Result as ValueResult, Value}, +}; diff --git a/src/settings/value.rs b/src/settings/value.rs index 08186238..6dff3320 100644 --- a/src/settings/value.rs +++ b/src/settings/value.rs @@ -1,9 +1,9 @@ -use crate::platform::prelude::*; use crate::{ component::splits::{ColumnStartWith, ColumnUpdateTrigger, ColumnUpdateWith}, hotkey::KeyCode, layout::LayoutDirection, - settings::{Alignment, Color, Gradient, ListGradient}, + platform::prelude::*, + settings::{Alignment, Color, Font, Gradient, ListGradient}, timing::formatter::{Accuracy, DigitsFormat}, TimingMethod, }; @@ -56,6 +56,9 @@ pub enum Value { Hotkey(Option), /// A value describing the direction of a layout. LayoutDirection(LayoutDirection), + /// A value describing a font to use. `None` if a default font should be + /// used. + Font(Option), } /// The Error type for values that couldn't be converted. @@ -229,6 +232,14 @@ impl Value { _ => Err(Error::WrongType), } } + + /// Tries to convert the value into a font. + pub fn into_font(self) -> Result> { + match self { + Value::Font(v) => Ok(v), + _ => Err(Error::WrongType), + } + } } impl Into for Value { @@ -344,3 +355,9 @@ impl Into for Value { self.into_layout_direction().unwrap() } } + +impl Into> for Value { + fn into(self) -> Option { + self.into_font().unwrap() + } +} diff --git a/src/timing/timer/tests/mod.rs b/src/timing/timer/tests/mod.rs index 5b35777a..54214593 100644 --- a/src/timing/timer/tests/mod.rs +++ b/src/timing/timer/tests/mod.rs @@ -1,6 +1,8 @@ -use crate::run::Editor; -use crate::tests_helper::{run_with_splits, run_with_splits_opt, start_run}; -use crate::{Run, Segment, TimeSpan, Timer, TimerPhase, TimingMethod}; +use crate::{ + run::Editor, + tests_helper::{run_with_splits, run_with_splits_opt, start_run}, + Run, Segment, TimeSpan, Timer, TimerPhase, TimingMethod, +}; mod mark_as_modified; mod variables; @@ -179,7 +181,7 @@ fn modifying_best_segment_time_fixes_segment_history() { .segment_history() .iter() .next() - .and_then(|&(_, t)| t.game_time), + .and_then(|(_, t)| t.game_time), new_best ); // The second segment's best segment time should have changed to 7.0. @@ -206,7 +208,7 @@ fn modifying_best_segment_time_fixes_segment_history() { .segment_history() .iter() .next() - .and_then(|&(_, t)| t.game_time), + .and_then(|(_, t)| t.game_time), Some(second - first) ); // The second segment's best segment time should also be unaffected. This is diff --git a/tests/layout_files/WithTimerGradientBackground.lsl b/tests/layout_files/WithTimerGradientBackground.lsl new file mode 100644 index 00000000..35d68219 --- /dev/null +++ b/tests/layout_files/WithTimerGradientBackground.lsl @@ -0,0 +1,150 @@ + + + Vertical + 1598 + 456 + 320 + 581 + -1 + -1 + + FFFFFFFF + FF1A1A1A + 00000000 + 03FFFFFF + FF2A302A + FF00BFFF + FFADFF2F + FF9ACD32 + FFCD5C5C + FFFF4500 + FFF0B012 + False + FFD3D3D3 + FFFFFF00 + 00000000 + 80000000 + + + + False + True + True + False + SolidColor + + 1 + 0 + 1 + + + + LiveSplit.Title.dll + + 1.7 + True + True + True + False + False + False + + False + True + FFFFFFFF + FF2A302A + FF131313 + Plain + False + False + False + True + + + + LiveSplit.Splits.dll + + 1.6 + FF0B365F + FF153574 + 19 + 1 + False + False + True + 20 + Seconds + False + FFA9A9A9 + FFFFFFFF + FFDCDCDC + True + FFEEE8AA + FFFFFFFF + FFA9A9A9 + True + True + False + 24 + True + 3.6 + Plain + 00FFFFFF + 01FFFFFF + Plain + True + Tenths + True + False + FFFFFFFF + False + False + 00000000 + + + 1.5 + +/- + DeltaorSplitTime + Current Comparison + Current Timing Method + + + + + + LiveSplit.Timer.dll + + 1.5 + 69 + 225 + 1.23 + False + False + FFAAAAAA + 00000000 + FF222222 + PlainWithDeltaColor + False + Current Timing Method + 35 + + + + LiveSplit.PreviousSegment.dll + + 1.6 + FFFFFFFF + False + FF2A302A + FF0D0D0D + Plain + Tenths + True + Current Comparison + False + False + Tenths + + + + \ No newline at end of file diff --git a/tests/layout_files/mod.rs b/tests/layout_files/mod.rs index 19487c24..a1ae91ba 100644 --- a/tests/layout_files/mod.rs +++ b/tests/layout_files/mod.rs @@ -4,3 +4,4 @@ pub const ALL: &[u8] = include_bytes!("All.lsl"); pub const DARK: &[u8] = include_bytes!("dark.lsl"); pub const SUBSPLITS: &[u8] = include_bytes!("subsplits.lsl"); pub const WSPLIT: &[u8] = include_bytes!("WSplit.lsl"); +pub const WITH_TIMER_GRADIENT_BACKGROUND: &[u8] = include_bytes!("WithTimerGradientBackground.lsl"); diff --git a/tests/layout_parsing.rs b/tests/layout_parsing.rs index a3f7402d..0a84716b 100644 --- a/tests/layout_parsing.rs +++ b/tests/layout_parsing.rs @@ -33,6 +33,13 @@ mod parse { livesplit(layout_files::WSPLIT); } + #[test] + fn with_timer_delta_background() { + livesplit(layout_files::WITH_TIMER_GRADIENT_BACKGROUND); + // FIXME: Add a rendering test to render out the gradient once we have + // support for this. + } + #[test] fn assert_order_of_default_columns() { use livesplit_core::component::splits; diff --git a/tests/rendering.rs b/tests/rendering.rs index 63c78408..6b31f65e 100644 --- a/tests/rendering.rs +++ b/tests/rendering.rs @@ -69,7 +69,7 @@ fn wsplit() { check_dims( &layout.state(&timer.snapshot()), [250, 300], - "j/j93Nnct/c=", + "jvHd3fnZPuc=", "wsplit", ); } @@ -87,9 +87,9 @@ fn all_components() { let state = layout.state(&timer.snapshot()); - check_dims(&state, [300, 800], "4eH3scnJtvE=", "all_components"); + check_dims(&state, [300, 800], "4en3sdHBp/E=", "all_components"); - check_dims(&state, [150, 800], "SWPXSWFFa2s=", "all_components_thin"); + check_dims(&state, [150, 800], "TXfHZWVJRmc=", "all_components_thin"); } #[test] @@ -121,7 +121,7 @@ fn dark_layout() { check( &layout.state(&timer.snapshot()), - "D8IAQiBYxQc=", + "T8IAQiBYxYc=", "dark_layout", ); } @@ -187,7 +187,7 @@ fn single_line_title() { check_dims( &layout.state(&timer.snapshot()), [300, 60], - "QCRZR091aAE=", + "SDUKwUNqbQA=", "single_line_title", ); } @@ -237,6 +237,7 @@ fn get_comparison_tolerance() -> u32 { } } +#[track_caller] fn check(state: &LayoutState, expected_hash_data: &str, name: &str) { check_dims(state, [300, 500], expected_hash_data, name); } diff --git a/tests/split_parsing.rs b/tests/split_parsing.rs index 5a580661..8a476bba 100644 --- a/tests/split_parsing.rs +++ b/tests/split_parsing.rs @@ -203,11 +203,7 @@ mod parse { #[test] fn splits_io_prefers_parsing_as_itself() { let run = composite::parse(file(run_files::GENERIC_SPLITS_IO), None, false).unwrap(); - assert!(if let TimerKind::Generic(_) = run.kind { - true - } else { - false - }); + assert!(matches!(run.kind, TimerKind::Generic(_))); } #[test]