Skip to content

Commit 9f35f0c

Browse files
DocAtPromptclaude
andcommitted
calendar: one left edge for the day's rules, and optional half hours (§ 7.12)
Each block sized its own marker column — hours for a schedule, tick boxes for a todo — and then started its rules wherever that column happened to end, so the page had a ragged left edge instead of one. _rules_left measures every block's column and hands them all the same edge to rule from. A schedule block may now rule the half hours (half_hours: true, off by default so nothing existing changes). With them on the hour rule steps up from FAINT to GUIDE, so the half stays subordinate rather than the two reading as equals. On a block that has no hours the key is refused, not ignored: a setting that does nothing where it stands is the silent almost-right of § 5.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d2c993d commit 9f35f0c

5 files changed

Lines changed: 91 additions & 10 deletions

File tree

ctrlgrid/data/presets/calendar-a4.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ week_view: { surface: lines, tasks: true }
8888
# The day page — an ordered list of blocks. Reorder, resize, drop or repeat.
8989
day:
9090
blocks:
91+
# `half_hours: true` rules the half hours too — the hour line then steps
92+
# up a shade so the half stays subordinate.
9193
- { type: schedule, from: 7, to: 22, height: 55%, surface: lines }
9294
- { type: todo, rows: 8, height: 20% }
9395
- { type: notes, height: rest, surface: grid }

ctrlgrid/generators/calendar.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ class DayBlock(Section):
118118
start_hour: int | None = Field(default=None, alias="from", ge=0, le=24)
119119
end_hour: int | None = Field(default=None, alias="to", ge=0, le=24)
120120
rows: int | None = Field(default=None, ge=1)
121+
#: Rule the half hours too. Off by default, so an existing definition draws
122+
#: exactly what it drew before.
123+
half_hours: bool = False
121124

122125
@field_validator("height")
123126
@classmethod
@@ -135,6 +138,12 @@ def _schedule_has_a_span(self) -> DayBlock:
135138
raise ValueError(
136139
f"schedule `from` ({self.start_hour}) must be before `to` ({self.end_hour})"
137140
)
141+
elif self.half_hours:
142+
# Refused rather than ignored: a key that does nothing where it
143+
# stands is the silent almost-right § 5.1 warns about.
144+
raise ValueError(
145+
f"`half_hours` belongs to a schedule block, not to a {self.type} one (§ 7)"
146+
)
138147
return self
139148

140149

ctrlgrid/generators/calendar_layout.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -682,19 +682,45 @@ def week_page(page: Page, cfg, n: Nav, months, weekdays, *, week_no, start_date,
682682
# ------------------------------------------------------------------- day blocks
683683

684684

685+
#: The hour numbers and the tick boxes sit in a marker column; this is the air
686+
#: between it and where the writing starts.
687+
_BLOCK_GUTTER = mm(3)
688+
689+
690+
def _rules_left(page: Page, blocks) -> int:
691+
"""The one left edge every block's rules start at (§ 7).
692+
693+
Each block has its own marker column — hours for a schedule, tick boxes for
694+
a todo — of its own width. Left to itself each would start its rules
695+
somewhere else, and the page would have a ragged left edge instead of one.
696+
"""
697+
marker = 0
698+
for b in blocks:
699+
if b.type == "schedule":
700+
size = pt(7)
701+
marker = max(marker, pt(1) + max(
702+
page.q.text_width(str(b.start_hour + i), family="sans", size=round(size))
703+
for i in range(max(1, b.end_hour - b.start_hour))
704+
))
705+
elif b.type == "todo":
706+
marker = max(marker, pt(1) + mm(4))
707+
return round(marker + _BLOCK_GUTTER) if marker else 0
708+
709+
685710
def _draw_blocks(page: Page, blocks, top) -> None:
686711
avail = page.H - top - pt(2)
687712
fixed = sum(int(b.height[:-1]) for b in blocks if b.height != "rest")
688713
rests = [b for b in blocks if b.height == "rest"]
689714
rest_h = round(avail * max(0, 100 - fixed) / 100 / len(rests)) if rests else 0
715+
rules_left = _rules_left(page, blocks)
690716
y = top
691717
for b in blocks:
692718
h = rest_h if b.height == "rest" else round(avail * int(b.height[:-1]) / 100)
693-
_draw_block(page, b, y, h)
719+
_draw_block(page, b, y, h, rules_left)
694720
y += h
695721

696722

697-
def _draw_block(page: Page, b, top, h) -> None:
723+
def _draw_block(page: Page, b, top, h, rules_left: int = 0) -> None:
698724
page.hline(0, page.W, top, 0.35, GUIDE)
699725
inner = top + pt(2)
700726
if b.type == "schedule":
@@ -706,20 +732,23 @@ def _draw_block(page: Page, b, top, h) -> None:
706732
# a space is half a digit wide, so ` 7` used to end 0.7 mm short of `10`.
707733
widths = {s: page.q.text_width(s, family="sans", size=round(size)) for s in labels}
708734
num_right = pt(1) + max(widths.values())
709-
# The rule starts just past the hours, not at a guessed inset, so an
710-
# all-single-digit span (7–9) does not leave a wide empty gutter.
711-
rule_left = num_right + mm(3)
735+
rule_left = rules_left or num_right + _BLOCK_GUTTER
736+
# With half hours on, the hour rule steps up to GUIDE so the half stays
737+
# subordinate in FAINT; without them it keeps the FAINT it always had.
738+
hour_color = GUIDE if b.half_hours else FAINT
712739
for i, label in enumerate(labels):
713740
ry = inner + i * rh
714741
page.text(num_right - widths[label], round(ry), label, size, INK)
715-
page.hline(rule_left, page.W, ry + rh - pt(1), 0.2, FAINT)
742+
if b.half_hours:
743+
page.hline(rule_left, page.W, ry + rh / 2 - pt(1), 0.2, FAINT)
744+
page.hline(rule_left, page.W, ry + rh - pt(1), 0.2, hour_color)
716745
elif b.type == "todo":
717746
rows = b.rows or max(1, int((h - pt(2)) // mm(8)))
718747
rh = (h - pt(2)) / rows
719748
for i in range(rows):
720749
ry = inner + i * rh
721750
page.box(pt(1), round(ry), mm(4), mm(4), 0.35, GUIDE)
722-
page.hline(mm(7), page.W, round(ry + mm(4)), 0.2, FAINT)
751+
page.hline(rules_left or mm(7), page.W, round(ry + mm(4)), 0.2, FAINT)
723752
else: # notes
724753
page.surface(0, inner, page.W, h - pt(4), b.surface)
725754

docs/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ needs a human *and* the user has chosen to defer PyPI for now (see *Not done*).
3939
uv sync --extra dev && uv run pytest && uv run ruff check .
4040
```
4141

42-
988 tests, all green, ruff clean. Sixty-odd commits on `main`, linear history,
42+
992 tests, all green, ruff clean. Sixty-odd commits on `main`, linear history,
4343
pushed to **[github.com/DocAtPrompt/ctrlgrid](https://github.com/DocAtPrompt/ctrlgrid)**
4444
(public); CI runs green there on Linux (3.11–3.13), macOS and Windows. Eleven
4545
presets — one per generator (`lines`, `dots`, `polar`, `form`, `maze`,

tests/test_calendar.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
day_count,
2424
days_of_year,
2525
)
26-
from ctrlgrid.generators.calendar_layout import pt
26+
from ctrlgrid.generators.calendar_layout import FAINT, GUIDE, pt
2727
from ctrlgrid.loader import loads
28-
from ctrlgrid.marks import Area, Polygon, Text
28+
from ctrlgrid.marks import Area, Polygon, Segment, Text
2929
from ctrlgrid.pages import build
3030
from ctrlgrid.writers.pdf import PdfWriter
3131

@@ -716,3 +716,44 @@ def test_the_hours_share_one_right_edge(self) -> None:
716716
for t in self._hours()
717717
}
718718
assert len(edges) == 1
719+
720+
721+
class TestDayBlockRules:
722+
"""The day's blocks share one left edge, and the schedule may rule halves."""
723+
724+
def _day(self, blocks):
725+
pages, _dests, _edges = _graph(cfg(day={"blocks": blocks}))
726+
return next(p for p in pages if p.dest == "day-2026-05-03")
727+
728+
def _rules(self, page, color):
729+
"""The writing rules only — a block's own top border spans the full
730+
width from x=0, and is not one of them."""
731+
return [m for m in page.marks
732+
if isinstance(m, Segment) and m.color == color
733+
and m.start.y == m.end.y and m.start.x > 0]
734+
735+
def test_the_blocks_start_their_rules_at_one_edge(self) -> None:
736+
# An hour column and a column of tick boxes are different widths; left
737+
# to themselves the two blocks ruled from different places.
738+
day = self._day([
739+
{"type": "schedule", "from": 7, "to": 12, "height": "50%"},
740+
{"type": "todo", "rows": 4, "height": "rest"},
741+
])
742+
assert len({m.start.x for m in self._rules(day, FAINT)}) == 1
743+
744+
def test_half_hours_are_off_by_default(self) -> None:
745+
day = self._day([{"type": "schedule", "from": 7, "to": 12, "height": "100%"}])
746+
assert len(self._rules(day, FAINT)) == 5 # one rule per hour
747+
assert self._rules(day, GUIDE) == [] # no hierarchy, nothing louder
748+
749+
def test_half_hours_add_a_lighter_rule_between_the_hours(self) -> None:
750+
day = self._day([
751+
{"type": "schedule", "from": 7, "to": 12, "height": "100%", "half_hours": True}
752+
])
753+
# The hour steps up to GUIDE so the half stays subordinate in FAINT.
754+
assert len(self._rules(day, GUIDE)) == 5
755+
assert len(self._rules(day, FAINT)) == 5
756+
757+
def test_half_hours_on_a_block_that_has_no_hours_is_refused(self) -> None:
758+
with pytest.raises(ValidationError, match="belongs to a schedule block"):
759+
cfg(day={"blocks": [{"type": "todo", "rows": 4, "half_hours": True}]})

0 commit comments

Comments
 (0)