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 {...@@ -497,7 +497,10 @@ impl PageScene {
497 for tag in &paragraph.tags {497 for tag in &paragraph.tags {
498 primitives.push(Primitive::Tag {498 primitives.push(Primitive::Tag {
499 tag,499 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 ],
501 });504 });
502 }505 }
503 }506 }
crates/canvas/src/gpu/tags.rs+8
...@@ -12,6 +12,10 @@ pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image {...@@ -12,6 +12,10 @@ pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image {
12 ),12 ),
13 TagIcon::Question => (include_str!("../../assets/tags/question.svg"), None),13 TagIcon::Question => (include_str!("../../assets/tags/question.svg"), None),
14 TagIcon::Music => (include_str!("../../assets/tags/music.svg"), None),14 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),
15 };19 };
16 let width = (size + 1.0).ceil() as u32;20 let width = (size + 1.0).ceil() as u32;
17 let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize];21 let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize];
...@@ -107,6 +111,10 @@ mod tests {...@@ -107,6 +111,10 @@ mod tests {
107 TagIcon::CheckBox { checked: true },111 TagIcon::CheckBox { checked: true },
108 TagIcon::Question,112 TagIcon::Question,
109 TagIcon::Music,113 TagIcon::Music,
114 TagIcon::Exclamation,
115 TagIcon::RedSquare,
116 TagIcon::YellowSquare,
117 TagIcon::BlueSquare,
110 ] {118 ] {
111 let image = rasterize(icon, size, phase);119 let image = rasterize(icon, size, phase);
112 assert_eq!(120 assert_eq!(
crates/canvas/src/outline.rs+142-21
...@@ -74,6 +74,10 @@ pub enum TagIcon {...@@ -74,6 +74,10 @@ pub enum TagIcon {
74 CheckBox { checked: bool },74 CheckBox { checked: bool },
75 Question,75 Question,
76 Music,76 Music,
77 Exclamation,
78 RedSquare,
79 YellowSquare,
80 BlueSquare,
77}81}
7882
79#[derive(Clone, Debug, PartialEq)]83#[derive(Clone, Debug, PartialEq)]
...@@ -155,7 +159,50 @@ impl ParagraphLayout {...@@ -155,7 +159,50 @@ impl ParagraphLayout {
155 if !spacing.is_finite() || spacing < 0.0 {159 if !spacing.is_finite() || spacing < 0.0 {
156 return Err(LayoutError::InvalidSpacing);160 return Err(LayoutError::InvalidSpacing);
157 }161 }
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 };
159 let mut markers = Vec::new();206 let mut markers = Vec::new();
160 let mut marker_x = indent;207 let mut marker_x = indent;
161 for id in paragraph.lists.iter().rev() {208 for id in paragraph.lists.iter().rev() {
...@@ -194,37 +241,26 @@ impl ParagraphLayout {...@@ -194,37 +241,26 @@ impl ParagraphLayout {
194 }241 }
195 text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?;242 text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?;
196 let mut tags = Vec::new();243 let mut tags = Vec::new();
197 for tag in paragraph.tags.iter().chain(&source.tags) {244 for tag in &tag_definitions {
198 let Some(Definition {245 let (tag, shape, label) = (tag.0, tag.1, tag.2);
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 }
215 let icon = match shape {246 let icon = match shape {
216 Some(0) => continue,247 Some(0) => continue,
217 Some(3) => TagIcon::CheckBox {248 Some(3) => TagIcon::CheckBox {
218 checked: tag.status & 1 != 0,249 checked: tag.status & 1 != 0,
219 },250 },
220 Some(15) => TagIcon::Question,251 Some(15) => TagIcon::Question,
252 Some(17) => TagIcon::Exclamation,
253 Some(100) => TagIcon::RedSquare,
254 Some(101) => TagIcon::YellowSquare,
255 Some(102) => TagIcon::BlueSquare,
221 Some(121) => TagIcon::Music,256 Some(121) => TagIcon::Music,
222 _ => return Err(LayoutError::UnsupportedContent),257 _ => return Err(LayoutError::UnsupportedContent),
223 };258 };
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;
225 tags.push(ParagraphTag {261 tags.push(ParagraphTag {
226 icon,262 icon,
227 origin: [marker_x, 0.0],263 origin: [x, 0.0],
228 label: label.clone().unwrap_or_default(),264 label: label.clone().unwrap_or_default(),
229 disabled: tag.status & 2 != 0,265 disabled: tag.status & 2 != 0,
230 });266 });
...@@ -292,6 +328,18 @@ pub(crate) fn arrange<'a>(...@@ -292,6 +328,18 @@ pub(crate) fn arrange<'a>(
292}328}
293329
294impl OutlineLayout {330impl 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
295 /// Innermost table cell containing a visible paragraph index.343 /// Innermost table cell containing a visible paragraph index.
296 pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> {344 pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> {
297 self.tables.iter().rev().find_map(|table| {345 self.tables.iter().rev().find_map(|table| {
...@@ -932,6 +980,10 @@ mod tests {...@@ -932,6 +980,10 @@ mod tests {
932 for (shape, expected) in [980 for (shape, expected) in [
933 (3, TagIcon::CheckBox { checked: true }),981 (3, TagIcon::CheckBox { checked: true }),
934 (15, TagIcon::Question),982 (15, TagIcon::Question),
983 (17, TagIcon::Exclamation),
984 (100, TagIcon::RedSquare),
985 (101, TagIcon::YellowSquare),
986 (102, TagIcon::BlueSquare),
935 (121, TagIcon::Music),987 (121, TagIcon::Music),
936 ] {988 ] {
937 definitions.insert(989 definitions.insert(
...@@ -1000,6 +1052,75 @@ mod tests {...@@ -1000,6 +1052,75 @@ mod tests {
1000 ));1052 ));
1001 }1053 }
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
1003 #[test]1124 #[test]
1004 fn title_uses_a_default_wrap_limit_and_keeps_date_after_wrapped_title() {1125 fn title_uses_a_default_wrap_limit_and_keeps_date_after_wrapped_title() {
1005 let mut title = Title {1126 let mut title = Title {
crates/snowbound/src/accessibility.rs+4
...@@ -366,6 +366,10 @@ impl Accessibility {...@@ -366,6 +366,10 @@ impl Accessibility {
366 TagIcon::CheckBox { .. } => "To do",366 TagIcon::CheckBox { .. } => "To do",
367 TagIcon::Question => "Question",367 TagIcon::Question => "Question",
368 TagIcon::Music => "Music",368 TagIcon::Music => "Music",
369 TagIcon::Exclamation => "Critical",
370 TagIcon::RedSquare => "Project A",
371 TagIcon::YellowSquare => "Project B",
372 TagIcon::BlueSquare => "Project C",
369 };373 };
370 let label = if tag.label.is_empty() {374 let label = if tag.label.is_empty() {
371 fallback375 fallback
crates/snowbound/src/main.rs+5-2
...@@ -1753,7 +1753,7 @@ fn page_hit_test(...@@ -1753,7 +1753,7 @@ fn page_hit_test(
1753 || outline.layouts().any(|(_, paragraph)| {1753 || outline.layouts().any(|(_, paragraph)| {
1754 paragraph.tags.iter().any(|tag| {1754 paragraph.tags.iter().any(|tag| {
1755 let origin = outline.origin();1755 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];
1757 let y = origin[1] + paragraph.origin[1] + tag.origin[1];1757 let y = origin[1] + paragraph.origin[1] + tag.origin[1];
1758 let size = canvas::outline::ParagraphTag::SIZE;1758 let size = canvas::outline::ParagraphTag::SIZE;
1759 (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1])1759 (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1])
...@@ -2211,7 +2211,10 @@ fn append_outline<'a>(...@@ -2211,7 +2211,10 @@ fn append_outline<'a>(
2211 for tag in &paragraph.tags {2211 for tag in &paragraph.tags {
2212 primitives.push(Primitive::Tag {2212 primitives.push(Primitive::Tag {
2213 tag,2213 tag,
2214 origin: [x, y + paragraph_origin[1]],2214 origin: [
2215 x + outline.shaped().tag_column_offset(),
2216 y + paragraph_origin[1],
2217 ],
2215 });2218 });
2216 }2219 }
2217 }2220 }
tools/canvas/README.md+2-2
...@@ -45,9 +45,9 @@ Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not...@@ -45,9 +45,9 @@ Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not
4545
46## Note tags46## 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
52## Optional font substitution52## Optional font substitution
5353