* [PATCH yew-widget-toolkit] calendar grid: add an option to hide the weekend columns
@ 2026-08-12 10:29 Dominik Csapak
0 siblings, 0 replies; only message in thread
From: Dominik Csapak @ 2026-08-12 10:29 UTC (permalink / raw)
To: yew-devel
A planning calendar for working days spends two of its seven columns
on cells nobody schedules into. hide_weekends drops them, so the five
working days get the full width.
The span geometry can no longer take its fusing edges from the column
index: without the weekend a row is not date-contiguous (a Wednesday
start runs Wed, Thu, Fri, Mon, Tue), so a segment now fuses with the
day actually drawn beside it. A Friday to Monday run then reads as one
bar rather than two squared stubs.
A theme narrows the grid template by the new class on the root,
pwt-calendar-hide-weekends.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
src/widget/calendar_grid.rs | 193 ++++++++++++++++++++++++++++++------
1 file changed, 162 insertions(+), 31 deletions(-)
diff --git a/src/widget/calendar_grid.rs b/src/widget/calendar_grid.rs
index bc06f51..8e13ee3 100644
--- a/src/widget/calendar_grid.rs
+++ b/src/widget/calendar_grid.rs
@@ -329,6 +329,15 @@ pub struct CalendarGrid {
#[prop_or_default]
pub week_start: WeekStart,
+ /// Drop Saturday and Sunday, leaving a five-column week of working days. The grid then emits
+ /// `pwt-calendar-hide-weekends` on its root, the stable name a theme narrows the grid template
+ /// by. The date window is unchanged (see [`visible_range`](Self::visible_range)), the weekend
+ /// days simply get no cell; a span bar covering them fuses across the gap, so a run from Friday
+ /// to Monday reads as one continuous bar. Off by default.
+ #[builder]
+ #[prop_or_default]
+ pub hide_weekends: bool,
+
/// The application's current civil date (`YYYY-MM-DD`); that cell gets
/// `pwt-calendar-day-today`.
#[builder(IntoPropValue, into_prop_value)]
@@ -591,6 +600,9 @@ impl CalendarGrid {
/// First and last visible date (inclusive) for a view around an anchor,
/// for fetching data covering exactly the rendered window. `None` when
/// the anchor is not a valid ISO date.
+ ///
+ /// [`hide_weekends`](Self::hide_weekends) does not narrow this: the window still spans whole
+ /// weeks, the weekend days just get no cell.
pub fn visible_range(
view: CalendarGridView,
anchor: &str,
@@ -600,6 +612,12 @@ impl CalendarGrid {
Some((start.to_iso(), start.add_days(count - 1).to_iso()))
}
+ /// Day columns per week row: five working days once the weekend is hidden, seven otherwise.
+ /// Every row is complete, since a window always spans whole weeks.
+ fn columns(&self) -> usize {
+ if self.hide_weekends { 5 } else { 7 }
+ }
+
/// The visible day cells in render order, with all per-day flags resolved. Computed once per
/// render so the pinned-lane pre-pass and the cell loop walk exactly the same window without
/// re-deriving every date.
@@ -625,6 +643,9 @@ impl CalendarGrid {
date: iso,
}
})
+ // A window spans whole weeks, so dropping the weekend leaves every row with the same
+ // five working days - the fixed row width the render loop and the lane pre-pass need.
+ .filter(|day| !(self.hide_weekends && day.is_weekend))
.collect()
}
}
@@ -750,24 +771,30 @@ fn weekday_label(weekday_monday0: u32) -> String {
}
}
-/// The geometry of `bar` on the cell for `date` at column `col` (0..6) within its week row, or
-/// `None` when the bar does not cover the day. ISO dates compare lexically, so no date arithmetic
-/// is needed.
+/// The geometry of `bar` on the cell for `date`, given the days of the cells left and right of it
+/// within the same week row (`None` at a row edge), or `None` when the bar does not cover the day.
+/// ISO dates compare lexically, so no date arithmetic is needed.
+///
+/// Taking the neighbouring days rather than a column index keeps the edges right when a hidden
+/// weekend makes a row's cells non-contiguous: a bar fuses with the cell actually drawn next to it,
+/// so a Friday-to-Monday run reads as one bar there just as it does across a shown weekend.
fn span_segment(
bar: &CalendarSpanBar,
date: &str,
- col: u32,
+ prev: Option<&str>,
+ next: Option<&str>,
show_start_marker: bool,
) -> Option<CalendarSpanSegment> {
- if date < bar.start.as_str() || date > bar.end.as_str() {
+ let is_covered = |day: &str| bar.start.as_str() <= day && day <= bar.end.as_str();
+ if !is_covered(date) {
return None;
}
let is_start = date == bar.start;
let is_end = date == bar.end;
- // A cell joins its left/right neighbour only mid-span and away from a week-row edge; the row
- // break at col 0 / col 6 squares the edge even when the span continues into the next row.
- let continues_left = !is_start && col != 0;
- let continues_right = !is_end && col != 6;
+ // A cell joins a neighbour only where that neighbour is a covered cell of the same row; the row
+ // break squares the edge even when the span continues into the next row.
+ let continues_left = prev.is_some_and(is_covered);
+ let continues_right = next.is_some_and(is_covered);
// Leftmost cell of the bar in this row but not the true start (a row-break continuation): the
// start marker stays off so the segment reads as carried over from the previous row.
let row_continues_from_prev = !is_start && !continues_left;
@@ -808,10 +835,14 @@ impl crate::props::IntoVTag for CalendarGrid {
);
}
for i in 0..7u32 {
+ let weekday = (header_start + i) % 7;
+ if self.hide_weekends && weekday >= 5 {
+ continue;
+ }
header_row = header_row.with_child(
Container::from_tag("div")
.class("pwt-calendar-weekday")
- .with_child(html! { { weekday_label((header_start + i) % 7) } }),
+ .with_child(html! { { weekday_label(weekday) } }),
);
}
let header: Html = header_row.into();
@@ -819,6 +850,7 @@ impl crate::props::IntoVTag for CalendarGrid {
// An unparsable anchor renders the header over an empty grid rather than panicking deep
// inside the view.
let days = self.days();
+ let cols = self.columns();
let mut grid = Container::from_tag("div").class("pwt-calendar-grid");
@@ -861,13 +893,13 @@ impl crate::props::IntoVTag for CalendarGrid {
lane
})
.collect();
- let num_rows = days.len().div_ceil(7);
+ let num_rows = days.len().div_ceil(cols);
let mut row_pinned_count = vec![0usize; num_rows];
for (i, bar) in pinned_bars.iter().enumerate() {
for (offset, day) in days.iter().enumerate() {
if bar.start.as_str() <= day.date.as_str() && day.date.as_str() <= bar.end.as_str()
{
- let row = offset / 7;
+ let row = offset / cols;
row_pinned_count[row] = row_pinned_count[row].max(pinned_lane[i] + 1);
}
}
@@ -879,7 +911,7 @@ impl crate::props::IntoVTag for CalendarGrid {
// Leading gutter cell, once per row before its seven days, when render_gutter opts in.
// It carries the row's first day, so the consumer labels it (its ISO week, say) and,
// via on_gutter_click, acts on the row.
- if offset % 7 == 0
+ if offset % cols == 0
&& let Some(render_gutter) = &self.render_gutter
{
let mut gutter = Container::from_tag("div").class("pwt-calendar-gutter-cell");
@@ -927,21 +959,26 @@ impl crate::props::IntoVTag for CalendarGrid {
// The cell body sits above the span bars, so a caller can keep its own content in view
// while the bars scroll. Bars live in their own scroll box: a day with dozens of them
- // scrolls that cell rather than growing the row. The column index within the week row
- // (cells flow 7 per row) decides the row-break edges. An uncovered day gets no box.
+ // scrolls that cell rather than growing the row. An uncovered day gets no box.
if let Some(render) = &self.render_day {
cell = cell.with_child(render.apply(&info));
}
- let col = (offset % 7) as u32;
+ // The days of the cells drawn beside this one within the week row decide the row-break
+ // edges; a row's first and last cell have no neighbour to fuse with.
+ let in_row = offset % cols;
+ let prev = (in_row > 0).then(|| days[offset - 1].date.as_str());
+ let next = (in_row + 1 < cols).then(|| days[offset + 1].date.as_str());
// The pinned zone, above the scroll box: one row of the reserved lanes, each holding
// its pinned bar's segment for this day or an equal-height spacer.
- let reserved = row_pinned_count.get(offset / 7).copied().unwrap_or(0);
+ let reserved = row_pinned_count.get(offset / cols).copied().unwrap_or(0);
if reserved > 0 {
let mut slots: Vec<Option<CalendarSpanSegment>> = vec![None; reserved];
for (i, bar) in pinned_bars.iter().enumerate() {
- if let Some(seg) = span_segment(bar, &info.date, col, self.span_start_marker) {
+ if let Some(seg) =
+ span_segment(bar, &info.date, prev, next, self.span_start_marker)
+ {
// `reserved` is this row's max pinned lane + 1, so a bar covering a day in
// the row always has `pinned_lane[i] < reserved`; the guard is a bounds
// safety net, never a real drop.
@@ -969,7 +1006,9 @@ impl crate::props::IntoVTag for CalendarGrid {
if !flow_bars.is_empty() {
let segments: Vec<CalendarSpanSegment> = flow_bars
.iter()
- .filter_map(|bar| span_segment(bar, &info.date, col, self.span_start_marker))
+ .filter_map(|bar| {
+ span_segment(bar, &info.date, prev, next, self.span_start_marker)
+ })
.collect();
if !segments.is_empty() {
let mut lanes = Container::from_tag("div").class("pwt-calendar-span-lanes");
@@ -993,6 +1032,10 @@ impl crate::props::IntoVTag for CalendarGrid {
if this.render_gutter.is_some() {
this.add_class("pwt-calendar-gutter");
}
+ // Likewise for the five-column working week, which the theme narrows the template to.
+ if this.hide_weekends {
+ this.add_class("pwt-calendar-hide-weekends");
+ }
this.std_props.into_vtag(
Cow::Borrowed("div"),
@@ -1036,7 +1079,7 @@ mod tests {
.selected_span("run-1")
.on_span_click(|_: CalendarSpanClick| {});
- let start = span_segment(&bar, "2026-06-08", 0, true).unwrap();
+ let start = span_segment(&bar, "2026-06-08", None, Some("2026-06-09"), true).unwrap();
let attrs = segment_attrs(&grid.render_span_segment(start));
assert_eq!(attrs.get("role").map(String::as_str), Some("button"));
assert_eq!(attrs.get("tabindex").map(String::as_str), Some("0"));
@@ -1045,7 +1088,14 @@ mod tests {
assert!(attrs["class"].contains("pwt-calendar-span-selected"));
assert!(attrs["style"].contains("--pwt-calendar-span-color"));
- let mid = span_segment(&bar, "2026-06-09", 1, true).unwrap();
+ let mid = span_segment(
+ &bar,
+ "2026-06-09",
+ Some("2026-06-08"),
+ Some("2026-06-10"),
+ true,
+ )
+ .unwrap();
let mid_attrs = segment_attrs(&grid.render_span_segment(mid));
assert_eq!(
mid_attrs.get("role").map(String::as_str),
@@ -1064,7 +1114,7 @@ mod tests {
// No on_span_click and no matching selection: bars carry geometry only, no role/tabindex.
let bar = CalendarSpanBar::new("2026-06-08", "2026-06-10", "Sprint").id("run-1");
let grid = CalendarGrid::new("2026-06-08");
- let start = span_segment(&bar, "2026-06-08", 0, true).unwrap();
+ let start = span_segment(&bar, "2026-06-08", None, Some("2026-06-09"), true).unwrap();
let attrs = segment_attrs(&grid.render_span_segment(start));
assert!(!attrs.contains_key("role"));
assert!(!attrs.contains_key("tabindex"));
@@ -1076,22 +1126,45 @@ mod tests {
// A Mon..Wed bar in a Monday-start row (cols 0,1,2): rounded start, squared middle, rounded
// end, fusing into one bar.
let bar = CalendarSpanBar::new("2026-06-08", "2026-06-10", "Sprint");
- let start = span_segment(&bar, "2026-06-08", 0, true).unwrap();
+ let start = span_segment(&bar, "2026-06-08", None, Some("2026-06-09"), true).unwrap();
assert!(start.is_start && !start.is_end);
assert!(!start.continues_left && start.continues_right);
assert_eq!(start.label, "Sprint");
- let mid = span_segment(&bar, "2026-06-09", 1, true).unwrap();
+ let mid = span_segment(
+ &bar,
+ "2026-06-09",
+ Some("2026-06-08"),
+ Some("2026-06-10"),
+ true,
+ )
+ .unwrap();
assert!(!mid.is_start && !mid.is_end);
assert!(mid.continues_left && mid.continues_right);
assert_eq!(mid.label, ""); // titled only once per row
- let end = span_segment(&bar, "2026-06-10", 2, true).unwrap();
+ let end = span_segment(
+ &bar,
+ "2026-06-10",
+ Some("2026-06-09"),
+ Some("2026-06-11"),
+ true,
+ )
+ .unwrap();
assert!(end.is_end && end.continues_left && !end.continues_right);
// Days outside the range yield no segment.
- assert!(span_segment(&bar, "2026-06-07", 6, true).is_none());
- assert!(span_segment(&bar, "2026-06-11", 3, true).is_none());
+ assert!(span_segment(&bar, "2026-06-07", Some("2026-06-06"), None, true).is_none());
+ assert!(
+ span_segment(
+ &bar,
+ "2026-06-11",
+ Some("2026-06-10"),
+ Some("2026-06-12"),
+ true
+ )
+ .is_none()
+ );
}
#[test]
@@ -1100,12 +1173,19 @@ mod tests {
// no bleed, and the next row's first cell (col 0) is a continuation - no start marker, but
// re-titled so the second row's bar is still labelled.
let bar = CalendarSpanBar::new("2026-06-13", "2026-06-15", "Trip");
- let sat = span_segment(&bar, "2026-06-13", 5, true).unwrap();
+ let sat = span_segment(
+ &bar,
+ "2026-06-13",
+ Some("2026-06-12"),
+ Some("2026-06-14"),
+ true,
+ )
+ .unwrap();
assert!(sat.is_start && sat.continues_right);
- let sun = span_segment(&bar, "2026-06-14", 6, true).unwrap();
+ let sun = span_segment(&bar, "2026-06-14", Some("2026-06-13"), None, true).unwrap();
assert!(!sun.continues_right && sun.continues_left);
assert!(sun.row_continues_to_next);
- let mon = span_segment(&bar, "2026-06-15", 0, true).unwrap();
+ let mon = span_segment(&bar, "2026-06-15", None, Some("2026-06-16"), true).unwrap();
assert!(!mon.is_start && !mon.continues_left); // row-break continuation, edge squared
assert!(mon.row_continues_from_prev);
assert_eq!(mon.label, "Trip"); // re-titled on the new row's leading cell
@@ -1116,12 +1196,63 @@ mod tests {
// A bar whose true start is off the visible window: its first visible cell must read as a
// continuation (no start marker) even at col 0.
let bar = CalendarSpanBar::new("2026-05-28", "2026-06-03", "Leave");
- let first_visible = span_segment(&bar, "2026-06-01", 0, true).unwrap();
+ let first_visible =
+ span_segment(&bar, "2026-06-01", None, Some("2026-06-02"), true).unwrap();
assert!(!first_visible.is_start);
assert!(first_visible.row_continues_from_prev);
assert!(!first_visible.continues_left);
}
+ #[test]
+ fn hidden_weekends_leave_five_working_days_per_row() {
+ let grid = CalendarGrid::new("2026-06-10").hide_weekends(true);
+ let days = grid.days();
+ assert_eq!(grid.columns(), 5);
+ assert_eq!(days.len(), 30); // the 42-cell month window minus its six weekends
+ assert!(days.iter().all(|day| !day.is_weekend));
+ // June 2026 starts on a Monday; the second row picks up after the weekend.
+ assert_eq!(days[0].date, "2026-06-01");
+ assert_eq!(days[4].date, "2026-06-05");
+ assert_eq!(days[5].date, "2026-06-08");
+ // A week view keeps its single row, five cells wide.
+ let week = CalendarGrid::new("2026-06-10")
+ .view(CalendarGridView::Week)
+ .hide_weekends(true);
+ assert_eq!(week.days().len(), 5);
+ // The date window itself is untouched, so a consumer still fetches whole weeks.
+ assert_eq!(
+ CalendarGrid::visible_range(CalendarGridView::Week, "2026-06-10", WeekStart::Monday),
+ Some(("2026-06-08".to_string(), "2026-06-14".to_string()))
+ );
+ }
+
+ #[test]
+ fn span_fuses_across_a_hidden_weekend() {
+ // A Wednesday-start row keeps Friday and Monday in the same row once the weekend is hidden
+ // (Wed, Thu, Fri, Mon, Tue), so a bar covering the weekend must join the two cells the grid
+ // actually draws side by side instead of squaring both edges.
+ let bar = CalendarSpanBar::new("2026-06-03", "2026-06-09", "Trip");
+ let fri = span_segment(
+ &bar,
+ "2026-06-05",
+ Some("2026-06-04"),
+ Some("2026-06-08"),
+ true,
+ )
+ .unwrap();
+ assert!(fri.continues_right && !fri.row_continues_to_next);
+ let mon = span_segment(
+ &bar,
+ "2026-06-08",
+ Some("2026-06-05"),
+ Some("2026-06-09"),
+ true,
+ )
+ .unwrap();
+ assert!(mon.continues_left && !mon.row_continues_from_prev);
+ assert_eq!(mon.label, ""); // still one label for the row, no fresh title after the gap
+ }
+
#[test]
fn civil_roundtrip_and_weekday() {
// 1970-01-01 is day 0, a Thursday (Monday = 0 -> 3).
--
2.47.3
^ permalink raw reply related [flat|nested] only message in thread
only message in thread, other threads:[~2026-08-12 10:31 UTC | newest]
Thread overview: (only message) (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-12 10:29 [PATCH yew-widget-toolkit] calendar grid: add an option to hide the weekend columns Dominik Csapak
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox