diff --git a/src/finance/blotter.rs b/src/finance/blotter.rs index aecef28..70c428d 100644 --- a/src/finance/blotter.rs +++ b/src/finance/blotter.rs @@ -16,7 +16,10 @@ use crate::{ motion::Flash, tables::{Aggregate, Cell, Column, DataTable, Row}, theme::{ActiveTheme, TextSize}, - typography::{format, tabular}, + typography::{ + format::{self, system_zone}, + tabular, + }, }; /// A held position: its symbol, how many, what each cost on average, and the last price. @@ -115,7 +118,7 @@ pub struct Working { pub struct OrderTable { id: ElementId, orders: Vec, - zone: TimeZone, + zone: Option, } impl OrderTable { @@ -132,13 +135,13 @@ impl OrderTable { Self { id: id.into(), orders, - zone: TimeZone::system(), + zone: None, } } /// The time zone its times read in; the system's unless set. pub fn zone(mut self, zone: TimeZone) -> Self { - self.zone = zone; + self.zone = Some(zone); self } } @@ -153,6 +156,10 @@ fn side_tag(side: Side) -> Cell { impl RenderOnce for OrderTable { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| system_zone("OrderTable")); let columns = [ Column::new("time", "Placed"), Column::new("symbol", "Symbol"), @@ -175,7 +182,7 @@ impl RenderOnce for OrderTable { }; let time: SharedString = working .time - .to_zoned(self.zone.clone()) + .to_zoned(zone.clone()) .strftime("%H:%M:%S") .to_string() .into(); @@ -214,7 +221,7 @@ pub struct Fill { pub struct TradeHistoryTable { id: ElementId, fills: Vec, - zone: TimeZone, + zone: Option, } impl TradeHistoryTable { @@ -224,19 +231,23 @@ impl TradeHistoryTable { Self { id: id.into(), fills, - zone: TimeZone::system(), + zone: None, } } /// The time zone its times read in; the system's unless set. pub fn zone(mut self, zone: TimeZone) -> Self { - self.zone = zone; + self.zone = Some(zone); self } } impl RenderOnce for TradeHistoryTable { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| system_zone("TradeHistoryTable")); let columns = [ Column::new("time", "Time"), Column::new("symbol", "Symbol"), @@ -261,7 +272,7 @@ impl RenderOnce for TradeHistoryTable { let total = signed * fill.quantity * fill.price - fill.fee; let time: SharedString = fill .time - .to_zoned(self.zone.clone()) + .to_zoned(zone.clone()) .strftime("%b %-d %H:%M") .to_string() .into(); diff --git a/src/finance/calendars.rs b/src/finance/calendars.rs index 712c3db..dda092e 100644 --- a/src/finance/calendars.rs +++ b/src/finance/calendars.rs @@ -11,7 +11,11 @@ use crate::{ forms::flag, tables::{Cell, Column, DataTable, Row}, theme::{ActiveTheme, TextSize}, - typography::{Ellipsis, format, tabular}, + typography::{ + Ellipsis, + format::{self, system_zone}, + tabular, + }, }; /// An economic release: when, for which region, what, how much it moves markets from one to three, and its figure as it came, as forecast and as before. @@ -31,7 +35,7 @@ pub struct Release { #[derive(IntoElement)] pub struct EconomicCalendar { releases: Vec, - zone: TimeZone, + zone: Option, } impl EconomicCalendar { @@ -45,18 +49,22 @@ impl EconomicCalendar { ); Self { releases, - zone: TimeZone::system(), + zone: None, } } pub fn zone(mut self, zone: TimeZone) -> Self { - self.zone = zone; + self.zone = Some(zone); self } } impl RenderOnce for EconomicCalendar { fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| system_zone("EconomicCalendar")); let theme = cx.theme(); let colors = theme.colors.clone(); self.releases.sort_by_key(|release| release.time); @@ -68,7 +76,7 @@ impl RenderOnce for EconomicCalendar { let mut out: Vec = Vec::new(); let mut day: Option = None; for release in &self.releases { - let zoned = release.time.to_zoned(self.zone.clone()); + let zoned = release.time.to_zoned(zone.clone()); if day != Some(zoned.date()) { day = Some(zoned.date()); out.push( @@ -179,6 +187,11 @@ pub struct Earnings { pub reported: Option, } +/// How far a report beat its estimate, as a share of the estimate's size: positive when better, whatever the sign. +pub(crate) fn surprise(estimate: f64, reported: f64) -> f64 { + (reported - estimate) / estimate.abs() +} + /// Earnings by date: each company, when in the day it reports, what is expected, and once reported, how far it beat or missed. #[derive(IntoElement)] pub struct EarningsCalendar { @@ -222,7 +235,7 @@ impl RenderOnce for EarningsCalendar { ); let surprise = match earnings.reported { Some(reported) if earnings.estimate != 0.0 => { - let share = reported / earnings.estimate - 1.0; + let share = surprise(earnings.estimate, reported); Cell::Tag( format::percent(share, 1, true).into(), if share >= 0.0 { @@ -270,7 +283,7 @@ type OnStory = Rc; pub struct NewsFeed { id: ElementId, stories: Vec, - zone: TimeZone, + zone: Option, on_open: Option, } @@ -279,13 +292,13 @@ impl NewsFeed { Self { id: id.into(), stories: stories.into_iter().collect(), - zone: TimeZone::system(), + zone: None, on_open: None, } } pub fn zone(mut self, zone: TimeZone) -> Self { - self.zone = zone; + self.zone = Some(zone); self } @@ -298,6 +311,7 @@ impl NewsFeed { impl RenderOnce for NewsFeed { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self.zone.clone().unwrap_or_else(|| system_zone("NewsFeed")); let theme = cx.theme(); let colors = theme.colors.clone(); let mut order: Vec = (0..self.stories.len()).collect(); @@ -315,7 +329,7 @@ impl RenderOnce for NewsFeed { let open = self.on_open.clone(); let when = story .time - .to_zoned(self.zone.clone()) + .to_zoned(zone.clone()) .strftime("%b %-d, %H:%M") .to_string(); div() @@ -376,3 +390,14 @@ impl RenderOnce for NewsFeed { })) } } + +#[cfg(test)] +mod tests { + use super::surprise; + + #[test] + fn a_smaller_loss_than_expected_is_a_beat() { + assert_eq!(surprise(-1.0, -0.5), 0.5); + assert_eq!(surprise(2.0, 1.5), -0.25); + } +} diff --git a/src/finance/candles.rs b/src/finance/candles.rs index 1fd26c0..378778b 100644 --- a/src/finance/candles.rs +++ b/src/finance/candles.rs @@ -162,14 +162,22 @@ pub(crate) fn profile( (low, high): (f64, f64), bands: usize, ) -> Vec<(f64, f64, f64)> { + assert!(bands > 0 && high > low, "a profile needs bands over a span"); let step = (high - low) / bands as f64; let mut traded = vec![0.0; bands]; for candle in candles { - let reach = (candle.high - candle.low).max(step); + let span = candle.high - candle.low; + if span <= 0.0 { + let band = ((candle.close - low) / step) + .floor() + .clamp(0.0, (bands - 1) as f64); + traded[band as usize] += candle.volume; + continue; + } for (band, volume) in traded.iter_mut().enumerate() { let (from, to) = (low + step * band as f64, low + step * (band + 1) as f64); let overlap = (to.min(candle.high) - from.max(candle.low)).max(0.0); - *volume += candle.volume * overlap / reach; + *volume += candle.volume * overlap / span; } } traded @@ -250,4 +258,20 @@ mod tests { ); assert_eq!((bands[3].0, bands[3].1), (13.0, 14.0)); } + + #[test] + fn narrow_and_flat_candles_count_all_their_volume() { + let narrow = Candle::new(Timestamp::UNIX_EPOCH, (10.2, 10.3, 10.1, 10.2), 100.0); + let flat = Candle::new(Timestamp::UNIX_EPOCH, (12.5, 12.5, 12.5, 12.5), 40.0); + let traded: Vec = profile(&[narrow, flat], (10.0, 14.0), 4) + .iter() + .map(|band| band.2) + .collect(); + assert!( + (traded[0] - 100.0).abs() < 1e-9, + "{traded:?}: a narrow candle's whole volume" + ); + assert_eq!(traded[2], 40.0, "a flat candle lands in its band"); + assert!((traded.iter().sum::() - 140.0).abs() < 1e-9); + } } diff --git a/src/finance/labels.rs b/src/finance/labels.rs index 81a8885..a66233c 100644 --- a/src/finance/labels.rs +++ b/src/finance/labels.rs @@ -225,7 +225,15 @@ pub(crate) fn labels( continue; }; let x = axes.visible.x(from.0.min(to.0), (main.x, main.w)); - for (share, value) in Drawing::levels(from, to) { + if x > main.x + main.w { + continue; + } + let x = x.max(main.x); + let inside = |y: f32| y >= main.y && y <= main.y + main.h; + for (share, value) in Drawing::levels(from, to) + .into_iter() + .filter(|(_, value)| inside(price.at(*value))) + { let words = format!( "{} {}", format::percent(share, 1, false), diff --git a/src/finance/market.rs b/src/finance/market.rs index 54a69f3..5557a8d 100644 --- a/src/finance/market.rs +++ b/src/finance/market.rs @@ -19,6 +19,7 @@ use super::{ use crate::{ charts::{Linear, Rect, measure}, theme::ActiveTheme, + typography::format::system_zone, }; /// How a market chart draws its prices. @@ -57,7 +58,7 @@ pub struct CandlestickChart { compare: Option<(SharedString, Rc>)>, sync: Option>, red_up: bool, - zone: TimeZone, + zone: Option, drawings: Vec, tool: Option, on_draw: Option, @@ -85,7 +86,7 @@ impl CandlestickChart { compare: None, sync: None, red_up: false, - zone: TimeZone::system(), + zone: None, drawings: Vec::new(), tool: None, on_draw: None, @@ -181,7 +182,7 @@ impl CandlestickChart { /// The time zone its times read in; the system's unless set. pub fn zone(mut self, zone: TimeZone) -> Self { - self.zone = zone; + self.zone = Some(zone); self } } @@ -194,6 +195,10 @@ impl Styled for CandlestickChart { impl RenderOnce for CandlestickChart { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| system_zone("CandlestickChart")); let sync = self.sync.clone(); let stage: Entity = window.use_keyed_state((self.id.clone(), "stage"), cx, |_, cx| Stage { @@ -236,11 +241,18 @@ impl RenderOnce for CandlestickChart { { visible.pan((total - seen) as f64, total) } - Some(visible) => visible, + Some(visible) => visible.fitted(total), None => Visible::latest(total, f64::from(main.w / pixels(sizes.candle)).max(1.0)), }; if seen != total { + log::debug!("market chart: {seen} candles to {total}"); stage.update(cx, |stage, _| stage.seen = total); + if held.0.is_some() { + match &self.sync { + Some(sync) => sync.update(cx, |sync, _| sync.visible = Some(visible)), + None => stage.update(cx, |stage, _| stage.visible = Some(visible)), + } + } } let range = visible.range(total); let shown = Rc::new(if self.kind == ChartKind::HeikinAshi { @@ -362,7 +374,7 @@ impl RenderOnce for CandlestickChart { range: range.clone(), hover, row, - zone: self.zone.clone(), + zone: zone.clone(), title: self.title.clone(), compare: self.compare.as_ref().map(|(name, _)| name.clone()), drawings: marks.clone(), diff --git a/src/finance/renko.rs b/src/finance/renko.rs index a4a2c69..c671cba 100644 --- a/src/finance/renko.rs +++ b/src/finance/renko.rs @@ -17,7 +17,10 @@ use crate::{ ChartTooltip, Linear, Rect, Tiles, anchored, at, finish, measure, place, ring, tracked, }, theme::{ActiveTheme, TextSize}, - typography::{format, tabular}, + typography::{ + format::{self, system_zone}, + tabular, + }, }; /// A mark on a price-only chart: its slot, the prices it spans, whether it rose, and its words. @@ -221,7 +224,7 @@ pub struct RenkoChart { id: ElementId, candles: Rc>, size: f64, - zone: TimeZone, + zone: Option, red_up: bool, } @@ -236,7 +239,7 @@ impl RenkoChart { id: id.into(), candles: candles.into(), size, - zone: TimeZone::system(), + zone: None, red_up: false, } } @@ -248,7 +251,7 @@ impl RenkoChart { /// The time zone its dates read in; the system's unless set. pub fn zone(mut self, zone: TimeZone) -> Self { - self.zone = zone; + self.zone = Some(zone); self } } @@ -261,13 +264,17 @@ impl Styled for RenkoChart { impl RenderOnce for RenkoChart { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| system_zone("RenkoChart")); let marks = renko(&self.candles, self.size) .into_iter() .enumerate() .map(|(slot, brick)| { let day = self.candles[brick.at] .time - .to_zoned(self.zone.clone()) + .to_zoned(zone.clone()) .strftime("%b %-d, %Y") .to_string(); let reading = format!( diff --git a/src/finance/stage.rs b/src/finance/stage.rs index 6e81241..ace0e57 100644 --- a/src/finance/stage.rs +++ b/src/finance/stage.rs @@ -26,6 +26,15 @@ impl Visible { .kept(total) } + /// Held to `total` candles after the data changed: no wider than they allow, and in reach of them. + pub(crate) fn fitted(self, total: usize) -> Self { + let count = self.count.clamp( + CLOSEST.min(total as f64).max(1.0), + (total as f64).max(1.0) * 1.1, + ); + Self { count, ..self }.kept(total) + } + /// Kept to what `total` candles allow: never before the first, at most a quarter window past the last. fn kept(self, total: usize) -> Self { let last = (total as f64 - self.count * 0.75).max(0.0); @@ -107,6 +116,21 @@ pub(crate) fn decimals(step: f64) -> usize { mod tests { use super::*; + #[test] + fn a_window_fits_data_that_shrank_under_it() { + let window = Visible { + start: 150.0, + count: 50.0, + }; + let fitted = window.fitted(12); + assert!(fitted.count <= 12.0 * 1.1); + let range = fitted.range(12); + assert!( + !range.is_empty() && range.end == 12, + "{range:?} shows the newest candles" + ); + } + #[test] fn a_window_maps_candles_across_its_frame() { let window = Visible { diff --git a/src/finance/tape.rs b/src/finance/tape.rs index 47b8b72..5963a39 100644 --- a/src/finance/tape.rs +++ b/src/finance/tape.rs @@ -9,12 +9,13 @@ use jiff::tz::TimeZone; use super::{ book::{Side, Trade, cell, heading, level}, quotes::{moves, price}, + stage::decimals, }; use crate::{ charts::compact, motion::Flash, theme::{ActiveTheme, Density, TextSize}, - typography::tabular, + typography::{format::system_zone, tabular}, }; /// Trades as they print, newest on top: the time, price and size tinted by the side that took them; trades of at least `large` stand bold, and the newest flashes in. @@ -25,7 +26,7 @@ pub struct TimeAndSales { large: f64, rows: usize, places: usize, - zone: TimeZone, + zone: Option, red_up: bool, } @@ -38,7 +39,7 @@ impl TimeAndSales { large: f64::MAX, rows: 12, places: 2, - zone: TimeZone::system(), + zone: None, red_up: false, } } @@ -55,7 +56,7 @@ impl TimeAndSales { } pub fn zone(mut self, zone: TimeZone) -> Self { - self.zone = zone; + self.zone = Some(zone); self } @@ -67,10 +68,13 @@ impl TimeAndSales { impl RenderOnce for TimeAndSales { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| system_zone("TimeAndSales")); let (rise, fall) = moves(self.red_up, cx); let theme = cx.theme(); let colors = theme.colors.clone(); - let newest = self.trades.len(); let rows = self .trades .iter() @@ -81,7 +85,7 @@ impl RenderOnce for TimeAndSales { let ink = if trade.side == Side::Buy { rise } else { fall }; let time = trade .time - .to_zoned(self.zone.clone()) + .to_zoned(zone.clone()) .strftime("%H:%M:%S") .to_string(); let row = div() @@ -104,7 +108,7 @@ impl RenderOnce for TimeAndSales { .child(cell(price(trade.price, self.places), ink)) .child(cell(compact(trade.size), colors.fg)); if ix == 0 { - Flash::new((self.id.clone(), "newest"), newest) + Flash::new((self.id.clone(), "newest"), *trade) .child(row) .into_any_element() } else { @@ -192,6 +196,7 @@ fn rungs(last: f64, tick: f64, rows: usize) -> Vec { impl RenderOnce for DomLadder { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let places = self.places.max(decimals(self.tick)); let (rise, fall) = moves(self.red_up, cx); let theme = cx.theme(); let colors = theme.colors.clone(); @@ -255,7 +260,7 @@ impl RenderOnce for DomLadder { .font_weight(FontWeight::SEMIBOLD) }) .when(!marked, |price| price.text_color(colors.fg_muted)) - .child(price(at, self.places)), + .child(price(at, places)), ) .child(side_cell(ask, Side::Sell, fall)) }); diff --git a/src/finance/wallet.rs b/src/finance/wallet.rs index 147b1dc..a332b4d 100644 --- a/src/finance/wallet.rs +++ b/src/finance/wallet.rs @@ -13,7 +13,11 @@ use crate::{ forms::{Combobox, NumberInput, TextInput}, primitives::{Icon, IconName}, theme::{ActiveTheme, IconSize, Radius, TextSize}, - typography::{Ellipsis, format, tabular}, + typography::{ + Ellipsis, + format::{self, system_zone}, + tabular, + }, }; type OnConvert = Rc; @@ -309,7 +313,7 @@ pub struct Transfer { #[derive(IntoElement)] pub struct TransactionList { transfers: Vec, - zone: TimeZone, + zone: Option, } impl TransactionList { @@ -318,13 +322,13 @@ impl TransactionList { transfers.sort_by_key(|transfer| Reverse(transfer.time)); Self { transfers, - zone: TimeZone::system(), + zone: None, } } /// The time zone its times read in; the system's unless set. pub fn zone(mut self, zone: TimeZone) -> Self { - self.zone = zone; + self.zone = Some(zone); self } } @@ -342,6 +346,10 @@ fn amount(value: f64, unit: &str) -> String { impl RenderOnce for TransactionList { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| system_zone("TransactionList")); let theme = cx.theme(); let colors = theme.colors.clone(); div() @@ -397,7 +405,7 @@ impl RenderOnce for TransactionList { .child( transfer .time - .to_zoned(self.zone.clone()) + .to_zoned(zone.clone()) .strftime("%b %-d, %H:%M") .to_string(), ), diff --git a/src/forms/date/mod.rs b/src/forms/date/mod.rs index 785e557..188be98 100644 --- a/src/forms/date/mod.rs +++ b/src/forms/date/mod.rs @@ -11,9 +11,10 @@ mod zone; use jiff::{ Timestamp, Zoned, civil::{Date, Time}, - tz::TimeZone, }; +use crate::typography::format::system_zone; + pub use calendar::Calendar; pub use cron::CronEditor; pub use period::{MonthPicker, QuarterPicker, YearPicker}; @@ -26,10 +27,7 @@ pub use zone::TimezoneSelect; /// The system's clock in its own zone. An unknown zone stops here, not in UTC. pub(crate) fn zoned_now() -> Zoned { - let zone = TimeZone::try_system().unwrap_or_else(|error| { - panic!("the system time zone is unknown; pass a fixed date: {error}") - }); - Timestamp::now().to_zoned(zone) + Timestamp::now().to_zoned(system_zone("date pickers")) } /// A day as "Sep 25, 2026". diff --git a/src/forms/number.rs b/src/forms/number.rs index b3f23bf..b67b8fd 100644 --- a/src/forms/number.rs +++ b/src/forms/number.rs @@ -102,6 +102,7 @@ fn commit(state: &Entity, next: f64, window: &mut Window, cx: &mut App) ) }; let value = settle(next, min, max, precision); + state.update(cx, |numeric, _| numeric.value = value); show(&input, on_change.as_ref(), value, precision, window, cx); } @@ -112,19 +113,36 @@ fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity .filter(|ch| ch.is_ascii_digit() || matches!(ch, '.' | ',' | '-' | format::MINUS)) }); let events = cx.subscribe_in(&input, window, |numeric, input, event, window, cx| { - if !matches!(event, InputEvent::Blur | InputEvent::Submit) { - return; - } let (min, max, _, precision) = numeric.limits; - let value = settle(numeric.current(cx), min, max, precision); - show( - input, - numeric.on_change.as_ref(), - value, - precision, - window, - cx, - ); + match event { + InputEvent::Blur | InputEvent::Submit => { + let value = settle(numeric.current(cx), min, max, precision); + numeric.value = value; + show( + input, + numeric.on_change.as_ref(), + value, + precision, + window, + cx, + ); + } + InputEvent::Changed if input.read(cx).focus().is_focused(window) => { + let Some(typed) = parse(input.read(cx).text()) else { + return; + }; + let settled = typed == settle(typed, min, max, precision); + if typed == numeric.value || !settled { + return; + } + log::info!("number input: typed {typed}"); + numeric.value = typed; + if let Some(on_change) = numeric.on_change.clone() { + on_change(typed, window, cx); + } + } + _ => {} + } }); Numeric { input, diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs index 9ad2638..ea497ba 100644 --- a/src/forms/tests/mod.rs +++ b/src/forms/tests/mod.rs @@ -183,6 +183,26 @@ fn stepping_starts_from_the_typed_number(cx: &mut TestAppContext) { assert_eq!(view.read_with(cx, |view, _| view.value), 21.0); } +#[gpui::test] +fn a_typed_number_reaches_the_owner_before_any_blur(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Stepped { value: 100.0 }); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("cmd-a"); + cx.simulate_input("250"); + assert_eq!( + view.read_with(cx, |view, _| view.value), + 250.0, + "a press elsewhere reads the typed number" + ); + cx.simulate_input("."); + assert_eq!( + view.read_with(cx, |view, _| view.value), + 250.0, + "a half-typed number waits" + ); +} + struct Pin { code: String, } diff --git a/src/typography/format.rs b/src/typography/format.rs index 7ebf0ee..b357dd9 100644 --- a/src/typography/format.rs +++ b/src/typography/format.rs @@ -173,6 +173,13 @@ pub fn relative(then: Timestamp, now: Timestamp) -> String { } } +/// The system's time zone for `user`. An unknown zone stops here rather than reading as UTC. +pub(crate) fn system_zone(user: &str) -> TimeZone { + TimeZone::try_system().unwrap_or_else(|error| { + panic!("{user}: the system time zone is unknown ({error}); pass a zone") + }) +} + /// strftime in `zone`; a bad pattern is an error. pub fn datetime(at: Timestamp, zone: &TimeZone, pattern: &str) -> Result { strtime::format(pattern, &at.to_zoned(zone.clone())) diff --git a/src/typography/formatted.rs b/src/typography/formatted.rs index ffb66c2..00c2246 100644 --- a/src/typography/formatted.rs +++ b/src/typography/formatted.rs @@ -229,9 +229,7 @@ impl RenderOnce for DateTimeText { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let zone = match self.zone { Some(zone) => zone, - None => TimeZone::try_system().unwrap_or_else(|error| { - panic!("DateTimeText: system time zone unknown ({error}); pass .zone()") - }), + None => format::system_zone("DateTimeText"), }; let text = format::datetime(self.at, &zone, self.pattern) .unwrap_or_else(|error| panic!("DateTimeText pattern {:?}: {error}", self.pattern));