1use crate::date::{TimeOfDay, Weekday};
5use crate::geometry::{Rect, Size};
6use crate::style::CellStyle;
7use crate::text;
8use crate::widget::{MeasureCx, PaintCx, Widget};
9
10pub(crate) const DAY: u32 = 86_400;
12
13const HOUR_STEPS: [u32; 9] = [15, 30, 60, 120, 180, 240, 360, 720, 1_440];
16
17const WHOLE_HOURS: u32 = 60;
19
20pub(crate) fn span_between(from: TimeOfDay, to: TimeOfDay) -> u32 {
23 let span = (to.seconds_since_midnight() + DAY - from.seconds_since_midnight()) % DAY;
24 if span == 0 { DAY } else { span }
25}
26
27pub(crate) fn time_cell(offset: u32, span: u32, width: u16) -> i32 {
31 let cell = u64::from(offset) * u64::from(width) / u64::from(span.max(1));
32 i32::try_from(cell).unwrap_or(i32::MAX)
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37enum Kind {
38 Hours { from: u32, span: u32 },
40 Weekdays { first: Weekday, count: usize },
42 Months { first: u8, count: usize },
44 Labels(Vec<String>),
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Axis {
76 kind: Kind,
77 gap: u16,
78 faint: bool,
79}
80
81impl Axis {
82 #[must_use]
85 pub fn hours(from: TimeOfDay, to: TimeOfDay) -> Self {
86 Self::of(Kind::Hours { from: from.seconds_since_midnight(), span: span_between(from, to) })
87 }
88
89 #[must_use]
91 pub fn weekdays(first: Weekday, count: usize) -> Self {
92 Self::of(Kind::Weekdays { first, count })
93 }
94
95 #[must_use]
98 pub fn months(first: u8, count: usize) -> Self {
99 Self::of(Kind::Months { first: first.clamp(1, 12), count })
100 }
101
102 #[must_use]
105 pub fn labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
106 Self::of(Kind::Labels(labels.into_iter().map(Into::into).collect()))
107 }
108
109 #[must_use]
112 pub fn gap(mut self, cells: u16) -> Self {
113 self.gap = cells;
114 self
115 }
116
117 fn of(kind: Kind) -> Self {
118 Self { kind, gap: 0, faint: false }
119 }
120
121 pub(crate) fn faint(mut self, faint: bool) -> Self {
123 self.faint = faint;
124 self
125 }
126
127 fn count(&self) -> usize {
129 match &self.kind {
130 Kind::Hours { .. } => 1,
131 Kind::Weekdays { count, .. } | Kind::Months { count, .. } => *count,
132 Kind::Labels(labels) => labels.len(),
133 }
134 }
135
136 fn place(&self, width: u16) -> Vec<(i32, String)> {
139 match &self.kind {
140 Kind::Hours { from, span } => hour_labels(*from, *span, width),
141 Kind::Weekdays { first, count } => {
142 let forms: Vec<Vec<String>> = (0..*count)
143 .map(|index| {
144 let day = (usize::from(first.number()) - 1 + index) % 7 + 1;
145 vec![
146 crate::t!(&format!("quvyta.date.weekday-long-{day}")),
147 crate::t!(&format!("quvyta.date.weekday-{day}")),
148 ]
149 })
150 .collect();
151 self.slot_labels(&forms, width)
152 }
153 Kind::Months { first, count } => {
154 let forms: Vec<Vec<String>> = (0..*count)
155 .map(|index| {
156 let month = (usize::from(*first) - 1 + index) % 12 + 1;
157 vec![
158 crate::t!(&format!("quvyta.date.month-{month}")),
159 crate::t!(&format!("quvyta.date.month-short-{month}")),
160 ]
161 })
162 .collect();
163 self.slot_labels(&forms, width)
164 }
165 Kind::Labels(labels) => {
166 let forms: Vec<Vec<String>> = labels.iter().map(|label| vec![label.clone()]).collect();
167 self.slot_labels(&forms, width)
168 }
169 }
170 }
171
172 fn slot_labels(&self, forms: &[Vec<String>], width: u16) -> Vec<(i32, String)> {
175 let count = forms.len();
176 if count == 0 || width == 0 {
177 return Vec::new();
178 }
179 let gaps = u16::try_from(count - 1).unwrap_or(u16::MAX).saturating_mul(self.gap);
180 let slots = u16::try_from(count).unwrap_or(u16::MAX);
181 let slot = (width.saturating_sub(gaps) / slots).max(1);
182 let place = |index: usize, label: &str| {
183 let start = i32::try_from(index).unwrap_or(i32::MAX).saturating_mul(i32::from(slot) + i32::from(self.gap));
184 let label_width = i32::from(text::width(label));
185 let x = start + (i32::from(slot) - label_width) / 2;
186 (x.min(i32::from(width) - label_width).max(0), label.to_owned())
189 };
190 let longest = forms.iter().map(Vec::len).max().unwrap_or(0);
191 for form in 0..longest {
192 let labels: Vec<(i32, String)> =
193 forms.iter().enumerate().map(|(index, f)| place(index, form_at(f, form))).collect();
194 if fits(&labels, width) {
195 return labels;
196 }
197 }
198 let shortest = longest.saturating_sub(1);
199 for step in 2..=count {
200 let labels: Vec<(i32, String)> = forms
201 .iter()
202 .enumerate()
203 .filter(|(index, _)| index % step == 0)
204 .map(|(index, f)| place(index, form_at(f, shortest)))
205 .collect();
206 if fits(&labels, width) {
207 return labels;
208 }
209 }
210 Vec::new()
211 }
212}
213
214fn form_at(forms: &[String], form: usize) -> &str {
216 forms.get(form).or_else(|| forms.last()).map_or("", String::as_str)
217}
218
219fn fits(labels: &[(i32, String)], width: u16) -> bool {
222 if labels.is_empty() {
223 return false;
224 }
225 let mut free_from = 0;
226 for (x, label) in labels {
227 let end = x + i32::from(text::width(label));
228 if *x < free_from || end > i32::from(width) {
229 return false;
230 }
231 free_from = end + 1;
232 }
233 true
234}
235
236fn hour_labels(from: u32, span: u32, width: u16) -> Vec<(i32, String)> {
240 let finest = |short: bool| {
241 HOUR_STEPS
242 .into_iter()
243 .filter(|step| !short || *step >= WHOLE_HOURS)
244 .map(|step| {
245 ticks(from, span, step)
246 .map(|tick| (time_cell(tick - from, span, width), clock_label(tick % DAY, short)))
247 .filter(|(x, label)| x + i32::from(text::width(label)) <= i32::from(width))
249 .collect::<Vec<_>>()
250 })
251 .find(|labels| fits(labels, width))
252 .unwrap_or_default()
253 };
254 let long = finest(false);
255 if long.len() >= 2 {
256 return long;
257 }
258 let short = finest(true);
259 if short.len() > long.len() { short } else { long }
260}
261
262fn ticks(from: u32, span: u32, step: u32) -> impl Iterator<Item = u32> {
265 let step = step * 60;
266 let first = from.div_ceil(step) * step;
267 (first..from + span).step_by(usize::try_from(step).unwrap_or(usize::MAX))
268}
269
270pub(crate) fn clock_label(seconds: u32, short: bool) -> String {
272 let time = TimeOfDay::from_seconds_since_midnight(seconds);
273 let hour = format!("{:02}", time.hour);
274 if short {
275 crate::t!("quvyta.time.hour", hour = hour)
276 } else {
277 crate::t!("quvyta.time.clock", hour = hour, minute = format!("{:02}", time.minute))
278 }
279}
280
281impl<Msg: 'static> Widget<Msg> for Axis {
282 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
283 if self.count() == 0 {
284 return Size::default();
285 }
286 Size::new(available.width, 1).min(available)
287 }
288
289 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
290 if area.is_empty() {
291 return;
292 }
293 let color = if self.faint {
294 cx.color("muted")
295 } else {
296 cx.style("axis", None, &[]).color("fg").unwrap_or_else(|| cx.color("dim"))
297 };
298 let style = CellStyle::fg(color);
299 for (x, label) in self.place(area.width) {
300 let width = text::width(&label);
301 cx.text(area.x + x, area.y, &label, style, width);
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::icons::GlyphMode;
310 use crate::runtime::{App, Command, Harness};
311 use crate::widget::{Length, View};
312 use crate::widgets::{Bar, BarChart};
313
314 struct Demo(Axis);
315
316 impl App for Demo {
317 type Msg = ();
318 fn update(&mut self, _: ()) -> Command<()> {
319 Command::none()
320 }
321 fn view(&self, ui: &mut View<'_, ()>) {
322 ui.add(self.0.clone()).width(Length::Fill(1)).height(Length::Fill(1));
323 }
324 }
325
326 fn screen(axis: Axis, width: u16) -> String {
327 Harness::new(Demo(axis), width, 1).screen().trim_end_matches('\n').to_owned()
328 }
329
330 fn time(hour: u8, minute: u8) -> TimeOfDay {
331 TimeOfDay::new(hour, minute, 0)
332 }
333
334 fn words(row: &str) -> Vec<&str> {
336 row.split_whitespace().collect()
337 }
338
339 #[test]
340 fn a_day_writes_round_hours_that_fit() {
341 let day = screen(Axis::hours(time(0, 0), time(0, 0)), 72);
342 assert_eq!(
343 words(&day),
344 [
345 "00:00", "02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00",
346 "22:00"
347 ]
348 );
349 assert!(day.starts_with("00:00 "), "{day}");
350 assert_eq!(day.find("12:00"), Some(36), "noon sits in the middle of a day: {day}");
351 }
352
353 #[test]
354 fn narrower_days_thin_the_hours_and_then_shorten_them() {
355 let wide = words(&screen(Axis::hours(time(0, 0), time(0, 0)), 48)).len();
356 let narrow = screen(Axis::hours(time(0, 0), time(0, 0)), 24);
357 assert!(wide < 12 && wide > 4, "48 cells thin the labels to a coarser step: {wide}");
358 assert_eq!(words(&narrow), ["00:00", "06:00", "12:00", "18:00"]);
359 let tiny = screen(Axis::hours(time(0, 0), time(0, 0)), 9);
360 assert_eq!(words(&tiny), ["00", "12"], "too narrow for two long labels, so the hours go short");
361 assert_eq!(words(&screen(Axis::hours(time(0, 0), time(0, 0)), 5)), ["00:00"], "one label is still a label");
362 assert_eq!(screen(Axis::hours(time(0, 0), time(0, 0)), 1), "", "no room for a label writes nothing");
363 }
364
365 #[test]
366 fn a_short_range_goes_down_to_the_quarter_hour() {
367 let morning = screen(Axis::hours(time(9, 0), time(10, 0)), 40);
368 assert_eq!(words(&morning), ["09:00", "09:15", "09:30", "09:45"]);
369 let off_round = screen(Axis::hours(time(9, 10), time(12, 10)), 36);
370 assert!(off_round.starts_with(" "), "a range that starts between labels leaves the start blank: {off_round}");
371 assert_eq!(words(&off_round), ["09:30", "10:00", "10:30", "11:00", "11:30"], "12:00 would run past the edge");
372 }
373
374 #[test]
375 fn a_range_across_midnight_counts_on_into_the_next_day() {
376 let night = screen(Axis::hours(time(22, 0), time(6, 0)), 48);
377 assert_eq!(words(&night), ["22:00", "23:00", "00:00", "01:00", "02:00", "03:00", "04:00", "05:00"]);
378 }
379
380 #[test]
381 fn labels_never_collide_or_get_cut_at_any_width() {
382 let axes = [
383 Axis::hours(time(0, 0), time(0, 0)),
384 Axis::hours(time(9, 10), time(11, 50)),
385 Axis::weekdays(Weekday::Monday, 7),
386 Axis::months(1, 12),
387 Axis::labels(["first week", "second week", "third week"]),
388 ];
389 for axis in axes {
390 for width in 0..90 {
391 let h = Harness::new(Demo(axis.clone()), width.max(1), 1);
392 let i18n = std::sync::Arc::new(crate::i18n::I18n::builtin());
393 let labels = crate::i18n::scope(i18n, || axis.place(width));
394 let row = h.screen();
395 for (x, label) in &labels {
396 assert!(*x >= 0 && *x + i32::from(text::width(label)) <= i32::from(width), "{axis:?} at {width}");
397 if width > 0 {
398 assert!(row.contains(label.as_str()), "{label} is written whole at {width}: {row}");
399 }
400 }
401 for pair in labels.windows(2) {
402 let end = pair[0].0 + i32::from(text::width(&pair[0].1));
403 assert!(pair[1].0 > end, "{axis:?} at {width}: {pair:?} touch");
404 }
405 assert!(!row.contains('…'), "an axis never cuts a label: {row}");
406 }
407 }
408 }
409
410 #[test]
411 fn weekdays_shorten_before_they_thin() {
412 let long = screen(Axis::weekdays(Weekday::Monday, 7), 70);
413 assert_eq!(words(&long), ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]);
414 let short = screen(Axis::weekdays(Weekday::Monday, 7), 28);
415 assert_eq!(words(&short), ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]);
416 let thin = screen(Axis::weekdays(Weekday::Monday, 7), 14);
417 assert_eq!(words(&thin), ["Mo", "We", "Fr", "Su"], "every second day when even the short names crowd");
418 let thinner = screen(Axis::weekdays(Weekday::Monday, 7), 12);
419 assert_eq!(words(&thinner), ["Mo", "Th", "Su"], "every third day when every second still touches");
420 let from_sunday = screen(Axis::weekdays(Weekday::Sunday, 2), 20);
421 assert_eq!(words(&from_sunday), ["Sunday", "Monday"], "the week wraps");
422 }
423
424 #[test]
425 fn months_come_from_the_active_language() {
426 let mut h = Harness::new(Demo(Axis::months(11, 3)), 40, 1);
427 assert_eq!(words(h.screen().trim_end()), ["November", "December", "January"]);
428 h.set_locale("tr");
429 assert_eq!(words(h.screen().trim_end()), ["Kasım", "Aralık", "Ocak"]);
430 let mut year = Harness::new(Demo(Axis::months(1, 12)), 60, 1);
431 assert_eq!(
432 words(year.screen().trim_end()),
433 ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
434 );
435 year.set_locale("tr");
436 assert_eq!(words(year.screen().trim_end())[..3], ["Oca", "Şub", "Mar"]);
437 let mut days = Harness::new(Demo(Axis::weekdays(Weekday::Monday, 2)), 30, 1);
438 days.set_locale("tr");
439 assert_eq!(words(days.screen().trim_end()), ["Pazartesi", "Salı"]);
440 }
441
442 #[test]
443 fn slots_with_the_chart_gap_stand_under_the_bars() {
444 struct Chart;
445 impl App for Chart {
446 type Msg = ();
447 fn update(&mut self, _: ()) -> Command<()> {
448 Command::none()
449 }
450 fn view(&self, ui: &mut View<'_, ()>) {
451 let bars = ["Mo", "Tu", "We", "Th", "Fr"].map(|day| Bar::new(day, 3.0));
452 ui.add(BarChart::new(bars).vertical().gap(2)).width(Length::Fill(1)).height(Length::Cells(4));
453 let days = ["Mo", "Tu", "We", "Th", "Fr"];
454 ui.add(Axis::labels(days).gap(2)).width(Length::Fill(1)).height(Length::Cells(1));
455 }
456 }
457 for width in [23, 30, 41, 56] {
458 let h = Harness::new(Chart, width, 5);
459 let screen = h.screen();
460 let rows: Vec<&str> = screen.lines().collect();
461 assert_eq!(rows[3], rows[4], "the axis writes the chart's own labels in the same cells at {width}");
462 }
463 }
464
465 #[test]
466 fn an_axis_is_text_in_every_glyph_mode() {
467 for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
468 let mut h = Harness::new(Demo(Axis::hours(time(0, 0), time(0, 0))), 24, 1);
469 h.set_glyph_mode(mode);
470 assert_eq!(words(h.screen().trim_end()), ["00:00", "06:00", "12:00", "18:00"], "{mode:?}");
471 assert_eq!(h.fg(0, 0), h.env().theme().color("dim"), "{mode:?}");
472 }
473 }
474
475 #[test]
476 fn nothing_to_label_measures_nothing() {
477 let h = Harness::new(Demo(Axis::labels(Vec::<String>::new())), 10, 1);
478 assert_eq!(h.screen(), "\n");
479 let axis = Axis::labels(["a"]);
480 assert_eq!(axis.count(), 1);
481 assert_eq!(Axis::weekdays(Weekday::Monday, 0).count(), 0);
482 }
483}