public inbox for yew-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: yew-devel@lists.proxmox.com
Subject: [PATCH yew-widget-toolkit] calendar grid: add an option to hide the weekend columns
Date: Wed, 12 Aug 2026 12:29:42 +0200	[thread overview]
Message-ID: <20260812103115.1710697-1-d.csapak@proxmox.com> (raw)

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





                 reply	other threads:[~2026-08-12 10:31 UTC|newest]

Thread overview: [no followups] expand[flat|nested]  mbox.gz  Atom feed

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260812103115.1710697-1-d.csapak@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=yew-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal