authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 02:36:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log4527a055161bfd2ca5f5beb5d38f2c6cceb675ad
tree11ccc72e94bad172eb0a8b691cf251144073bbe0
parent9954b152f54c18a0150bbf7ef92fc56b4c23d2a8
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: draw every tag the notebook uses as OneNote does

Five of the notebook's lyric and script pages opened with text boxes read-only because of their tags. Font-colour tags (Action, Mood) now paint the paragraph, the newest colour tag winning; Critical and the Project A/B/C squares have icons drawn from OneNote 2010 captures; and tags follow OneNote's order and column: stored newest first but drawn oldest first, in one column per outline sized by its most-tagged paragraph. "m: the train song" matches OneNote position for position. Assisted-by: claude-opus-5.5

10 files changed, 231 insertions(+), 26 deletions(-)

crates/canvas/assets/tags/blue-square.svg created+19
......@@ -0,0 +1,19 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#52719c"/>
5 <stop offset="1" stop-color="#4a6994"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#73a2e7"/>
9 <stop offset="1" stop-color="#7baeef"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#6386bd"/>
13 <stop offset="1" stop-color="#5a7db5"/>
14 </linearGradient>
15 </defs>
16 <path d="M3 3H13V13H3Z" fill="url(#layer0)"/>
17 <path d="M3 3H12V12H3Z" fill="url(#layer1)"/>
18 <path d="M4 4H12V12H4Z" fill="url(#layer2)"/>
19</svg>
crates/canvas/assets/tags/exclamation.svg created+9
......@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#ff7d52"/>
5 <stop offset="1" stop-color="#c4471f"/>
6 </linearGradient>
7 </defs>
8 <path d="M8 1.5C9.6 1.5 9.9 2.7 9.6 4.4L8.6 9.5H7.4L6.4 4.4C6.1 2.7 6.4 1.5 8 1.5ZM8 11C8.8 11 9.3 11.5 9.3 12.3C9.3 13.1 8.8 13.6 8 13.6C7.2 13.6 6.7 13.1 6.7 12.3C6.7 11.5 7.2 11 8 11Z" fill="url(#layer0)"/>
9</svg>
crates/canvas/assets/tags/red-square.svg created+19
......@@ -0,0 +1,19 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#ce5963"/>
5 <stop offset="1" stop-color="#a5494a"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ff7173"/>
9 <stop offset="1" stop-color="#ff717b"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#d66163"/>
13 <stop offset="1" stop-color="#ce5963"/>
14 </linearGradient>
15 </defs>
16 <path d="M3 3H13V13H3Z" fill="url(#layer0)"/>
17 <path d="M3 3H12V12H3Z" fill="url(#layer1)"/>
18 <path d="M4 4H12V12H4Z" fill="url(#layer2)"/>
19</svg>
crates/canvas/assets/tags/yellow-square.svg created+19
......@@ -0,0 +1,19 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#ce9a52"/>
5 <stop offset="1" stop-color="#946d39"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#fff37b"/>
9 <stop offset="1" stop-color="#ffeb7b"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#ffc763"/>
13 <stop offset="1" stop-color="#efb25a"/>
14 </linearGradient>
15 </defs>
16 <path d="M3 3H13V13H3Z" fill="url(#layer0)"/>
17 <path d="M3 3H12V12H3Z" fill="url(#layer1)"/>
18 <path d="M4 4H12V12H4Z" fill="url(#layer2)"/>
19</svg>
crates/canvas/src/gpu/page.rs+4-1
......@@ -497,7 +497,10 @@ impl PageScene {
497497 for tag in &paragraph.tags {
498498 primitives.push(Primitive::Tag {
499499 tag,
500 origin: [object_origin[0], object_origin[1] + paragraph.origin[1]],
500 origin: [
501 object_origin[0] + outline.tag_column_offset(),
502 object_origin[1] + paragraph.origin[1],
503 ],
501504 });
502505 }
503506 }
crates/canvas/src/gpu/tags.rs+8
......@@ -12,6 +12,10 @@ pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image {
1212 ),
1313 TagIcon::Question => (include_str!("../../assets/tags/question.svg"), None),
1414 TagIcon::Music => (include_str!("../../assets/tags/music.svg"), None),
15 TagIcon::Exclamation => (include_str!("../../assets/tags/exclamation.svg"), None),
16 TagIcon::RedSquare => (include_str!("../../assets/tags/red-square.svg"), None),
17 TagIcon::YellowSquare => (include_str!("../../assets/tags/yellow-square.svg"), None),
18 TagIcon::BlueSquare => (include_str!("../../assets/tags/blue-square.svg"), None),
1519 };
1620 let width = (size + 1.0).ceil() as u32;
1721 let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize];
......@@ -107,6 +111,10 @@ mod tests {
107111 TagIcon::CheckBox { checked: true },
108112 TagIcon::Question,
109113 TagIcon::Music,
114 TagIcon::Exclamation,
115 TagIcon::RedSquare,
116 TagIcon::YellowSquare,
117 TagIcon::BlueSquare,
110118 ] {
111119 let image = rasterize(icon, size, phase);
112120 assert_eq!(
crates/canvas/src/outline.rs+142-21
......@@ -74,6 +74,10 @@ pub enum TagIcon {
7474 CheckBox { checked: bool },
7575 Question,
7676 Music,
77 Exclamation,
78 RedSquare,
79 YellowSquare,
80 BlueSquare,
7781}
7882
7983#[derive(Clone, Debug, PartialEq)]
......@@ -155,7 +159,50 @@ impl ParagraphLayout {
155159 if !spacing.is_finite() || spacing < 0.0 {
156160 return Err(LayoutError::InvalidSpacing);
157161 }
158 let mut text = engine.layout(projection.text(), width - indent)?;
162 // Stored newest first; OneNote lists and paints tags oldest first.
163 let mut tag_definitions = paragraph
164 .tags
165 .iter()
166 .chain(&source.tags)
167 .map(
168 |tag| match tag.definition.as_ref().and_then(|id| definitions.get(id)) {
169 Some(Definition {
170 kind:
171 Kind::TagDefinition {
172 shape,
173 label,
174 color,
175 highlight,
176 ..
177 },
178 ..
179 }) if tag.status & 4 == 0 => Ok((tag, *shape, label, *color, *highlight)),
180 _ => Err(LayoutError::UnsupportedContent),
181 },
182 )
183 .collect::<Result<Vec<_>, _>>()?;
184 tag_definitions.reverse();
185 // The newest tag that sets a colour wins.
186 let color = tag_definitions.iter().rev().find_map(|tag| tag.3);
187 let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4);
188 let mut text = if color.is_none() && highlight.is_none() {
189 engine.layout(projection.text(), width - indent)?
190 } else {
191 let visible = projection.text();
192 let mut start = 0;
193 let runs = visible.spans().iter().map(|span| {
194 let mut format = span.format.clone();
195 format.color = color.or(format.color);
196 format.highlight = highlight.or(format.highlight);
197 let run = (visible.text()[start..span.end].to_owned(), format);
198 start = span.end;
199 run
200 });
201 engine.layout(
202 &Paragraph::from_runs(runs.collect::<Vec<_>>()),
203 width - indent,
204 )?
205 };
159206 let mut markers = Vec::new();
160207 let mut marker_x = indent;
161208 for id in paragraph.lists.iter().rev() {
......@@ -194,37 +241,26 @@ impl ParagraphLayout {
194241 }
195242 text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?;
196243 let mut tags = Vec::new();
197 for tag in paragraph.tags.iter().chain(&source.tags) {
198 let Some(Definition {
199 kind:
200 Kind::TagDefinition {
201 shape,
202 label,
203 color,
204 highlight,
205 ..
206 },
207 ..
208 }) = tag.definition.as_ref().and_then(|id| definitions.get(id))
209 else {
210 return Err(LayoutError::UnsupportedContent);
211 };
212 if color.is_some() || highlight.is_some() || tag.status & 4 != 0 {
213 return Err(LayoutError::UnsupportedContent);
214 }
244 for tag in &tag_definitions {
245 let (tag, shape, label) = (tag.0, tag.1, tag.2);
215246 let icon = match shape {
216247 Some(0) => continue,
217248 Some(3) => TagIcon::CheckBox {
218249 checked: tag.status & 1 != 0,
219250 },
220251 Some(15) => TagIcon::Question,
252 Some(17) => TagIcon::Exclamation,
253 Some(100) => TagIcon::RedSquare,
254 Some(101) => TagIcon::YellowSquare,
255 Some(102) => TagIcon::BlueSquare,
221256 Some(121) => TagIcon::Music,
222257 _ => return Err(LayoutError::UnsupportedContent),
223258 };
224 marker_x -= 20.25;
259 // Later tags follow the first to its right, toward the text.
260 let x = marker_x - 20.25 + 12.0 * tags.len() as f32;
225261 tags.push(ParagraphTag {
226262 icon,
227 origin: [marker_x, 0.0],
263 origin: [x, 0.0],
228264 label: label.clone().unwrap_or_default(),
229265 disabled: tag.status & 2 != 0,
230266 });
......@@ -292,6 +328,18 @@ pub(crate) fn arrange<'a>(
292328}
293329
294330impl OutlineLayout {
331 /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each
332 /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column.
333 pub fn tag_column_offset(&self) -> f32 {
334 let widest = self
335 .paragraphs
336 .iter()
337 .map(|p| p.tags.len())
338 .max()
339 .unwrap_or(0);
340 -12.0 * widest.saturating_sub(1) as f32
341 }
342
295343 /// Innermost table cell containing a visible paragraph index.
296344 pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> {
297345 self.tables.iter().rev().find_map(|table| {
......@@ -932,6 +980,10 @@ mod tests {
932980 for (shape, expected) in [
933981 (3, TagIcon::CheckBox { checked: true }),
934982 (15, TagIcon::Question),
983 (17, TagIcon::Exclamation),
984 (100, TagIcon::RedSquare),
985 (101, TagIcon::YellowSquare),
986 (102, TagIcon::BlueSquare),
935987 (121, TagIcon::Music),
936988 ] {
937989 definitions.insert(
......@@ -1000,6 +1052,75 @@ mod tests {
10001052 ));
10011053 }
10021054
1055 #[test]
1056 fn tags_paint_oldest_first_and_the_newest_colour_wins() {
1057 use onestore::document::Tag;
1058 let mut definitions = BTreeMap::new();
1059 let mut tag = |n, shape, color| {
1060 let id = ExGuid {
1061 n,
1062 ..ExGuid::default()
1063 };
1064 definitions.insert(
1065 id,
1066 Definition {
1067 kind: Kind::TagDefinition {
1068 shape: Some(shape),
1069 label: None,
1070 action_type: None,
1071 color,
1072 highlight: None,
1073 },
1074 format: Format::default(),
1075 },
1076 );
1077 Tag {
1078 definition: Some(id),
1079 status: 1,
1080 action_type: None,
1081 created: None,
1082 completed: None,
1083 start: None,
1084 due: None,
1085 task_id: None,
1086 extra_set: 0,
1087 }
1088 };
1089 let [action, mood, project, question] = [
1090 tag(1, 0, Some(0x0080_0080)),
1091 tag(2, 0, Some(0x0080_8000)),
1092 tag(3, 100, None),
1093 tag(4, 15, None),
1094 ];
1095 let mut node = paragraph(1, "Lyric", 1, None);
1096 // As stored: newest first.
1097 node.text_mut().unwrap().tags = vec![question, project, mood, action];
1098 let mut engine = TextEngine::default();
1099 let shaped =
1100 ParagraphLayout::shape(&mut engine, &node, 200.0, &[0.0, 0.0], &definitions).unwrap();
1101 assert_eq!(
1102 shaped
1103 .tags
1104 .iter()
1105 .map(|tag| (tag.icon, tag.origin[0]))
1106 .collect::<Vec<_>>(),
1107 [(TagIcon::RedSquare, -20.25), (TagIcon::Question, -8.25)]
1108 );
1109 let colors: Vec<_> = shaped
1110 .text
1111 .lines()
1112 .flat_map(|(line, _)| line.items().collect::<Vec<_>>())
1113 .filter_map(|item| match item {
1114 parley::PositionedLayoutItem::GlyphRun(run) => Some(run.style().brush.color),
1115 _ => None,
1116 })
1117 .collect();
1118 assert_eq!(colors, [0x0080_8000]);
1119 assert_eq!(shaped.projection.text().spans()[0].format.color, None);
1120 let outline = OutlineLayout::new(vec![shaped], 200.0, false).unwrap();
1121 assert_eq!(outline.tag_column_offset(), -12.0);
1122 }
1123
10031124 #[test]
10041125 fn title_uses_a_default_wrap_limit_and_keeps_date_after_wrapped_title() {
10051126 let mut title = Title {
crates/snowbound/src/accessibility.rs+4
......@@ -366,6 +366,10 @@ impl Accessibility {
366366 TagIcon::CheckBox { .. } => "To do",
367367 TagIcon::Question => "Question",
368368 TagIcon::Music => "Music",
369 TagIcon::Exclamation => "Critical",
370 TagIcon::RedSquare => "Project A",
371 TagIcon::YellowSquare => "Project B",
372 TagIcon::BlueSquare => "Project C",
369373 };
370374 let label = if tag.label.is_empty() {
371375 fallback
crates/snowbound/src/main.rs+5-2
......@@ -1753,7 +1753,7 @@ fn page_hit_test(
17531753 || outline.layouts().any(|(_, paragraph)| {
17541754 paragraph.tags.iter().any(|tag| {
17551755 let origin = outline.origin();
1756 let x = origin[0] + tag.origin[0];
1756 let x = origin[0] + outline.shaped().tag_column_offset() + tag.origin[0];
17571757 let y = origin[1] + paragraph.origin[1] + tag.origin[1];
17581758 let size = canvas::outline::ParagraphTag::SIZE;
17591759 (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1])
......@@ -2211,7 +2211,10 @@ fn append_outline<'a>(
22112211 for tag in &paragraph.tags {
22122212 primitives.push(Primitive::Tag {
22132213 tag,
2214 origin: [x, y + paragraph_origin[1]],
2214 origin: [
2215 x + outline.shaped().tag_column_offset(),
2216 y + paragraph_origin[1],
2217 ],
22152218 });
22162219 }
22172220 }
tools/canvas/README.md+2-2
......@@ -45,9 +45,9 @@ Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not
4545
4646## Note tags
4747
48Paragraph layout retains blue checkbox (shape 3), question mark (15) and musical note (121) tags from paragraph and rich-text nodes. Their 12 pt tiles precede the list marker or text origin with a 20.25 pt gutter; they do not change text advances, wrapping or line height. These dimensions cover the three reference pages' 17 tags. Hand-authored vector paths use Swash's existing rasterizer and the text atlas, including its subpixel phases, offscreen culling and eviction. No Office bitmap assets or tag fonts are bundled.
48Paragraph layout retains checkbox (shape 3), question mark (15), exclamation (17), red, yellow and blue square (100–102) and musical note (121) tags from paragraph and rich-text nodes. Tags are stored newest first and drawn oldest first: an outline has one tag column, as wide as its most-tagged paragraph needs (12 pt per tag), whose left edge sits 20.25 pt plus 12 pt per extra tag before the list marker or text origin, and each paragraph's tags start at that edge. Tags do not change text advances, wrapping or line height. A tag's font colour or highlight paints the whole paragraph, the newest such tag winning; shape 0 draws no icon. Positions, order and colours match OneNote 2010 on the notebook's lyric pages. Hand-authored vector paths use Swash's existing rasterizer and the text atlas, including its subpixel phases, offscreen culling and eviction. No Office bitmap assets or tag fonts are bundled.
4949
50Checkboxes display completion; noncheckable tags ignore the completion bit required by the file format. Disabled tags dim. Accessibility descriptions expose source labels and checkbox/disabled state without changing selectable text. Tag state is read-only; text edits and undo retain it. Clicking a tag gutter focuses its outline. Unsupported icon shapes, task tags and tag color/highlight overrides return `UnsupportedContent` during layout.
50Checkboxes display completion; noncheckable tags ignore the completion bit required by the file format. Disabled tags dim. Accessibility descriptions expose source labels and checkbox/disabled state without changing selectable text. Tag state is read-only; text edits and undo retain it. Clicking a tag gutter focuses its outline. Unsupported icon shapes and task tags return `UnsupportedContent` during layout.
5151
5252## Optional font substitution
5353