authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 10:57:51-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log73142aea074afbea99f51a217ef30bbc6bbe4863
treecf8eac86cf9ce6ce5f7adecac63141d58add85b6
parentac8ebd9b6dfd7e1c002ec56155fc01c435562209
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: draw links blue and underlined as OneNote does

Link text drew as plain text. Runs marked as a hyperlink now take OneNote's blue (0, 0, 255) and an underline, unless the run has a colour of its own. Assisted-by: claude-opus-5.5

1 files changed, 45 insertions(+), 2 deletions(-)

crates/canvas/src/layout.rs+45-2
...@@ -135,6 +135,7 @@ impl TextEngine {...@@ -135,6 +135,7 @@ impl TextEngine {
135 let mut start = 0;135 let mut start = 0;
136 for (index, span) in paragraph.spans().iter().enumerate() {136 for (index, span) in paragraph.spans().iter().enumerate() {
137 let format = &span.format;137 let format = &span.format;
138 let link = format.hyperlink == Some(true);
138 let size = format.font_size.unwrap_or(11.0);139 let size = format.font_size.unwrap_or(11.0);
139 if !size.is_finite() || size <= 0.0 {140 if !size.is_finite() || size <= 0.0 {
140 return Err(LayoutError::InvalidFontSize);141 return Err(LayoutError::InvalidFontSize);
...@@ -160,10 +161,15 @@ impl TextEngine {...@@ -160,10 +161,15 @@ impl TextEngine {
160 FontStyle::Normal161 FontStyle::Normal
161 }),162 }),
162 StyleProperty::Brush(TextBrush {163 StyleProperty::Brush(TextBrush {
163 color: format.color.unwrap_or(0),164 // Links take OneNote's blue unless given a colour of their own.
165 color: match format.color {
166 Some(color) if color != 0xff000000 => color,
167 _ if link => 0x00ff0000,
168 _ => 0,
169 },
164 highlight: format.highlight.filter(|color| *color != 0xff000000),170 highlight: format.highlight.filter(|color| *color != 0xff000000),
165 }),171 }),
166 StyleProperty::Underline(format.underline.unwrap_or(false)),172 StyleProperty::Underline(format.underline.unwrap_or(false) || link),
167 StyleProperty::Strikethrough(format.strike.unwrap_or(false)),173 StyleProperty::Strikethrough(format.strike.unwrap_or(false)),
168 ];174 ];
169 for property in properties {175 for property in properties {
...@@ -554,6 +560,43 @@ mod tests {...@@ -554,6 +560,43 @@ mod tests {
554 assert_eq!(fonts.len(), 1);560 assert_eq!(fonts.len(), 1);
555 }561 }
556562
563 #[test]
564 fn links_draw_blue_and_underlined_unless_coloured() {
565 let mut engine = TextEngine::default();
566 let link = Format {
567 hyperlink: Some(true),
568 ..Format::default()
569 };
570 let layout = engine
571 .layout(
572 &Paragraph::from_runs([
573 ("plain ".into(), Format::default()),
574 ("link".into(), link.clone()),
575 (
576 " red".into(),
577 Format {
578 color: Some(0x0000_00ff),
579 ..link
580 },
581 ),
582 ]),
583 300.0,
584 )
585 .unwrap();
586 let runs: Vec<_> = layout
587 .lines()
588 .flat_map(|(line, _)| line.items().collect::<Vec<_>>())
589 .filter_map(|item| match item {
590 PositionedLayoutItem::GlyphRun(run) => {
591 let style = run.style();
592 Some((style.brush.color, style.underline.is_some()))
593 }
594 _ => None,
595 })
596 .collect();
597 assert_eq!(runs, [(0, false), (0x00ff_0000, true), (0x0000_00ff, true)]);
598 }
599
557 #[test]600 #[test]
558 fn paragraph_alignment_offsets_lines_within_the_wrap_width() {601 fn paragraph_alignment_offsets_lines_within_the_wrap_width() {
559 let mut engine = TextEngine::default();602 let mut engine = TextEngine::default();