* [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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.