authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 22:22:07-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log263f6a1b7979ef2640fe4d7cfba53c4b9974b442
tree4508408f2d24eb501cc69404a9350872277eac42
parente0b4334a77134228b5f773ce7cb9227fc2dd027d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the toolbar formats the page

Undo and redo, bold, italic, underline, strikethrough, sub- and superscript, font and size lists over their boxes, font colour and highlight grids, bullets, numbering, indent, outdent, alignment and the To Do and Question tags act on the page, and toggles light while the selection has them. Assisted-by: claude-opus-5.5

7 files changed, 301 insertions(+), 51 deletions(-)

crates/snowbound/assets/icons/align-center.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5H13.5M4.5 6.5H11.5M2.5 9.5H13.5M4.5 12.5H11.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/align-right.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5H13.5M6.5 6.5H13.5M2.5 9.5H13.5M6.5 12.5H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/superscript.svg created+4
......@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 4.5L9.5 12.5M9.5 4.5L2.5 12.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3 <path d="M11 2.5C11.2 1.4 13.6 1.3 13.6 2.6C13.6 3.8 11 4.3 11 5.5H13.8" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
4</svg>
crates/snowbound/src/art.rs+3
......@@ -6,6 +6,8 @@ macro_rules! art {
66 };
77}
88
9pub const ALIGN_CENTER: &[&str] = art!("icons/align-center");
10pub const ALIGN_RIGHT: &[&str] = art!("icons/align-right");
911pub const ALIGN_LEFT: &[&str] = art!("icons/align-left");
1012pub const ATTACHMENT: &[&str] = art!("icons/attachment");
1113pub const BOLD: &[&str] = art!("icons/bold");
......@@ -30,6 +32,7 @@ pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");
3032pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");
3133pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough");
3234pub const SUBSCRIPT: &[&str] = art!("icons/subscript");
35pub const SUPERSCRIPT: &[&str] = art!("icons/superscript");
3336pub const TABLE: &[&str] = art!("icons/table");
3437pub const UNDERLINE: &[&str] = art!("icons/underline");
3538pub const UNDO: &[&str] = art!("icons/undo");
crates/snowbound/src/main.rs+278-48
......@@ -9,7 +9,10 @@ use canvas::{
99 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline},
1010 layout::TextEngine,
1111};
12use canvas::{gpu::tag_sources, outline::TagIcon};
12use canvas::{
13 gpu::{colorref, tag_sources},
14 outline::TagIcon,
15};
1316use draw::Renderer;
1417use onestore::ExGuid;
1518use onestore::document::Format;
......@@ -42,6 +45,22 @@ const FRAME: f32 = 6.0;
4245/// macOS rounds windows' corners by 10 pt; the page's corners share their centres.
4346const ROUNDING: f32 = 10.0 - FRAME;
4447const PAGE_LIST: f32 = 240.0;
48/// Font sizes the size box offers, OneNote's list in points.
49const SIZES: [f32; 17] = [
50 8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0,
51 72.0,
52];
53/// OneNote's highlight colours, COLORREF.
54const HIGHLIGHTS: &[u32] = &[
55 0x00ffff, 0x00ff00, 0xffff00, 0xff00ff, 0xff0000, 0x0000ff, 0x800000, 0x808000, 0x008000,
56 0x800080, 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000,
57];
58/// Office's theme and standard font colours, COLORREF.
59const FONT_COLORS: [u32; 20] = [
60 0xffffff, 0x000000, 0xe1ecee, 0x7d491f, 0xbd814f, 0x4d50c0, 0x59bb9b, 0xa26480, 0xc6ac4b,
61 0x4696f7, 0x0000c0, 0x0000ff, 0x00c0ff, 0x00ffff, 0x50d092, 0x50b000, 0xf0b000, 0xc07000,
62 0x602000, 0xa03070,
63];
4564/// Height of a page tab's row, whose tab leaves `ROW_GAP` below it so tabs stand apart
4665/// while the gaps still take the pointer.
4766const ROW: f32 = 29.0;
......@@ -235,6 +254,8 @@ struct State {
235254 filter: String,
236255 /// Whether the page list is shown beside the page.
237256 pages_open: bool,
257 /// Installed font families, for the font box; listing them is slow.
258 fonts: Vec<String>,
238259 /// The application's icon at the display's scale, for the title bar.
239260 app_icon: Option<draw::RasterImage>,
240261 commands: Vec<Command>,
......@@ -426,6 +447,14 @@ impl State {
426447 eprintln!("Using {family} for the interface");
427448 }
428449 ui.set_focus(Some(page()));
450 let mut fonts: Vec<String> = engine
451 .fonts
452 .collection
453 .family_names()
454 .map(String::from)
455 .collect();
456 fonts.sort_unstable_by_key(|name| name.to_lowercase());
457 fonts.dedup();
429458 let state = Self {
430459 window,
431460 redraw: Arc::new(Redraw(proxy.clone())).into(),
......@@ -447,6 +476,7 @@ impl State {
447476 session,
448477 filter: String::new(),
449478 pages_open: true,
479 fonts,
450480 app_icon: macos::app_icon((16.0 * dpr).round() as u32),
451481 commands: Vec::new(),
452482 changed: false,
......@@ -813,8 +843,27 @@ impl State {
813843 /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home
814844 /// ribbon groups them.
815845 fn toolbar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {
846 use canvas::editor::{Alignment, FormatState, Formatting, NoteTag, Toggle};
847 // A focused picture has no text to show a format for.
848 let state = self
849 .view
850 .editor
851 .format_state()
852 .unwrap_or_else(|_| FormatState {
853 toggles: Vec::new(),
854 font: None,
855 font_size: None,
856 alignment: None,
857 bullets: false,
858 numbering: false,
859 tags: Vec::new(),
860 });
861 let fonts = &self.fonts;
816862 let ui = &mut self.ui;
817863 let text = theme.text;
864 let on = |toggle| state.toggles.contains(&toggle);
865 let mut command = None;
866 let mut history = None;
818867 ui.open(
819868 "toolbar",
820869 Spec {
......@@ -826,65 +875,232 @@ impl State {
826875 },
827876 );
828877 group(ui, "history", |ui| {
829 ui::shell::tool_button(ui, "undo", art::UNDO, text);
830 ui::shell::tool_button(ui, "redo", art::REDO, text);
878 if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked {
879 history = Some(true);
880 }
881 if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked {
882 history = Some(false);
883 }
831884 });
832885 divider(ui, "history", theme);
886 let popup = |name: &str| Id::ROOT.child(("popup", name));
887 let font = state.font.clone().unwrap_or_default();
888 let size = state
889 .font_size
890 .map_or(String::new(), |size| format!("{size}"));
833891 group(ui, "text", |ui| {
834892 row(ui, 0, |ui| {
835 ui::shell::combo(ui, "font", "Calibri", 120.0);
836 ui::shell::combo(ui, "size", "11", 44.0);
837 ui::shell::split_button(ui, "bullets", art::BULLETS, None);
838 ui::shell::split_button(ui, "numbering", art::NUMBERING, None);
839 ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text);
893 let combo = ui.id("font");
894 if ui::shell::combo(ui, "font", &font, 120.0).clicked {
895 ui.open_popup(popup("font"));
896 }
897 let items: Vec<_> = fonts
898 .iter()
899 .map(|name| ui::popup::Item {
900 text: name,
901 checked: *name == font,
902 ..Default::default()
903 })
904 .collect();
905 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
906 if let Some(index) = ui::popup::menu(ui, popup("font"), anchor, &items, Some(&font))
907 {
908 command = Some(Formatting::Font(fonts[index].clone()));
909 }
910 let combo = ui.id("size");
911 if ui::shell::combo(ui, "size", &size, 44.0).clicked {
912 ui.open_popup(popup("size"));
913 }
914 let labels = SIZES.map(|size| format!("{size}"));
915 let items: Vec<_> = labels
916 .iter()
917 .map(|label| ui::popup::Item {
918 text: label,
919 checked: *label == size,
920 ..Default::default()
921 })
922 .collect();
923 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
924 if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size))
925 {
926 command = Some(Formatting::FontSize(SIZES[index]));
927 }
928 let bullets =
929 ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets);
930 let numbering =
931 ui::shell::split_button(ui, "numbering", art::NUMBERING, None, state.numbering);
932 if bullets.iter().any(|signal| signal.clicked) {
933 command = Some(Formatting::Bullets);
934 }
935 if numbering.iter().any(|signal| signal.clicked) {
936 command = Some(Formatting::Numbering);
937 }
938 if ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text, false).clicked {
939 command = Some(Formatting::Clear);
940 }
840941 });
841942 row(ui, 1, |ui| {
842 for (part, icon) in [
843 ("bold", art::BOLD),
844 ("italic", art::ITALIC),
845 ("underline", art::UNDERLINE),
846 ("strikethrough", art::STRIKETHROUGH),
943 for (part, icon, toggle) in [
944 ("bold", art::BOLD, Toggle::Bold),
945 ("italic", art::ITALIC, Toggle::Italic),
946 ("underline", art::UNDERLINE, Toggle::Underline),
947 ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough),
847948 ] {
848 ui::shell::tool_button(ui, part, icon, text);
949 if ui::shell::tool_button(ui, part, icon, text, on(toggle)).clicked {
950 command = Some(Formatting::Toggle(toggle));
951 }
849952 }
850 ui::shell::split_button(ui, "script", art::SUBSCRIPT, None);
851 ui::shell::split_button(
852 ui,
853 "highlight",
854 art::HIGHLIGHTER,
855 Some(draw::srgb(0xff, 0xff, 0x00)),
856 );
857 ui::shell::split_button(
953 let script = ui.id("script");
954 let superscript = on(Toggle::Superscript);
955 let [button, menu] = ui::shell::split_button(
858956 ui,
859 "color",
860 art::FONT_COLOR,
861 Some(draw::srgb(0xe8, 0x3a, 0x30)),
957 "script",
958 if superscript {
959 art::SUPERSCRIPT
960 } else {
961 art::SUBSCRIPT
962 },
963 None,
964 on(Toggle::Subscript) || superscript,
862965 );
863 ui::shell::tool_button(ui, "outdent", art::OUTDENT, text);
864 ui::shell::tool_button(ui, "indent", art::INDENT, text);
865 ui::shell::split_button(ui, "align", art::ALIGN_LEFT, None);
966 if button.clicked {
967 command = Some(Formatting::Toggle(if superscript {
968 Toggle::Superscript
969 } else {
970 Toggle::Subscript
971 }));
972 }
973 if menu.clicked {
974 ui.open_popup(popup("script"));
975 }
976 let items = [
977 ui::popup::Item {
978 text: "Subscript",
979 icon: Some(art::SUBSCRIPT),
980 shortcut: "โŒ˜=",
981 checked: on(Toggle::Subscript),
982 ..Default::default()
983 },
984 ui::popup::Item {
985 text: "Superscript",
986 icon: Some(art::SUPERSCRIPT),
987 shortcut: "โ‡งโŒ˜=",
988 checked: superscript,
989 ..Default::default()
990 },
991 ];
992 let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default());
993 if let Some(index) = ui::popup::menu(ui, popup("script"), anchor, &items, None) {
994 command = Some(Formatting::Toggle(
995 [Toggle::Subscript, Toggle::Superscript][index],
996 ));
997 }
998 for (part, icon, swatches, columns, none, default) in [
999 (
1000 "highlight",
1001 art::HIGHLIGHTER,
1002 HIGHLIGHTS,
1003 5,
1004 "No colour",
1005 0x00ffff,
1006 ),
1007 (
1008 "color",
1009 art::FONT_COLOR,
1010 &FONT_COLORS[..],
1011 10,
1012 "Automatic",
1013 0x3a3ae8,
1014 ),
1015 ] {
1016 let split = ui.id(part);
1017 let [button, menu] =
1018 ui::shell::split_button(ui, part, icon, Some(colorref(default)), false);
1019 let paint = |color| {
1020 if part == "highlight" {
1021 Formatting::Highlight(color)
1022 } else {
1023 Formatting::Color(color)
1024 }
1025 };
1026 if button.clicked {
1027 command = Some(paint(Some(default)));
1028 }
1029 if menu.clicked {
1030 ui.open_popup(popup(part));
1031 }
1032 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();
1033 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
1034 if let Some(chosen) =
1035 ui::popup::colors(ui, popup(part), anchor, none, &colors, columns)
1036 {
1037 command = Some(paint(chosen.and_then(|chosen| {
1038 swatches
1039 .iter()
1040 .zip(&colors)
1041 .find(|(_, color)| **color == chosen)
1042 .map(|(stored, _)| *stored)
1043 })));
1044 }
1045 }
1046 if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked {
1047 command = Some(Formatting::Outdent);
1048 }
1049 if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked {
1050 command = Some(Formatting::Indent);
1051 }
1052 let align = ui.id("align");
1053 let alignments = [
1054 (Alignment::Left, "Align left", art::ALIGN_LEFT),
1055 (Alignment::Center, "Centre", art::ALIGN_CENTER),
1056 (Alignment::Right, "Align right", art::ALIGN_RIGHT),
1057 ];
1058 let current = alignments
1059 .iter()
1060 .find(|(alignment, ..)| Some(*alignment) == state.alignment)
1061 .unwrap_or(&alignments[0]);
1062 let [button, menu] = ui::shell::split_button(ui, "align", current.2, None, false);
1063 if button.clicked || menu.clicked {
1064 ui.open_popup(popup("align"));
1065 }
1066 let items = alignments.map(|(alignment, name, icon)| ui::popup::Item {
1067 text: name,
1068 icon: Some(icon),
1069 checked: Some(alignment) == state.alignment,
1070 ..Default::default()
1071 });
1072 let anchor = ui::Anchor::Below(ui.rect(align).unwrap_or_default());
1073 if let Some(index) = ui::popup::menu(ui, popup("align"), anchor, &items, None) {
1074 command = Some(Formatting::Align(alignments[index].0));
1075 }
8661076 });
8671077 });
8681078 divider(ui, "text", theme);
869 let tags = [
870 tag_sources(TagIcon::CheckBox { checked: false }),
871 art::TAG_STAR,
872 tag_sources(TagIcon::Question),
873 art::TAG_REMEMBER,
874 art::TAG_DEFINITION,
875 art::TAG_HIGHLIGHT,
876 art::TAG_CONTACT,
877 art::TAG_ADDRESS,
878 art::TAG_PHONE,
1079 let tags: [(&[&str], Option<NoteTag>); 9] = [
1080 (
1081 tag_sources(TagIcon::CheckBox { checked: false }),
1082 Some(NoteTag::ToDo),
1083 ),
1084 (art::TAG_STAR, None),
1085 (tag_sources(TagIcon::Question), Some(NoteTag::Question)),
1086 (art::TAG_REMEMBER, None),
1087 (art::TAG_DEFINITION, None),
1088 (art::TAG_HIGHLIGHT, None),
1089 (art::TAG_CONTACT, None),
1090 (art::TAG_ADDRESS, None),
1091 (art::TAG_PHONE, None),
8791092 ];
8801093 group(ui, "tags", |ui| {
8811094 for (index, tags) in tags.chunks(5).enumerate() {
8821095 row(ui, index, |ui| {
883 for (column, icon) in tags.iter().enumerate() {
884 ui::shell::tool_button(ui, column, icon, [1.0; 4]);
1096 for (column, (icon, tag)) in tags.iter().enumerate() {
1097 let lit = tag.is_some_and(|tag| state.tags.contains(&tag));
1098 if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked {
1099 command = tag.map(Formatting::Tag);
1100 }
8851101 }
8861102 if index == 1 {
887 ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text);
1103 ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, false);
8881104 }
8891105 });
8901106 }
......@@ -898,7 +1114,7 @@ impl State {
8981114 ("file", art::ATTACHMENT),
8991115 ("link", art::LINK),
9001116 ] {
901 ui::shell::tool_button(ui, part, icon, text);
1117 ui::shell::tool_button(ui, part, icon, text, false);
9021118 }
9031119 });
9041120 row(ui, 1, |ui| {
......@@ -907,7 +1123,7 @@ impl State {
9071123 ("time", art::CLOCK),
9081124 ("equation", art::EQUATION),
9091125 ] {
910 ui::shell::tool_button(ui, part, icon, text);
1126 ui::shell::tool_button(ui, part, icon, text, false);
9111127 }
9121128 });
9131129 });
......@@ -923,7 +1139,7 @@ impl State {
9231139 let mut chosen = None;
9241140 group(ui, "zoom", |ui| {
9251141 row(ui, 0, |ui| {
926 if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text).clicked {
1142 if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text, false).clicked {
9271143 chosen = Some(zoom / 1.1);
9281144 }
9291145 let level = ui.leaf(
......@@ -941,7 +1157,7 @@ impl State {
9411157 if level.clicked {
9421158 chosen = Some(1.0);
9431159 }
944 if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text).clicked {
1160 if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text, false).clicked {
9451161 chosen = Some(zoom * 1.1);
9461162 }
9471163 });
......@@ -951,6 +1167,19 @@ impl State {
9511167 let response = self.view.set_zoom(zoom)?;
9521168 self.respond(response);
9531169 }
1170 if let Some(command) = command {
1171 let response = self.view.format(command)?;
1172 self.respond(response);
1173 }
1174 if let Some(undo) = history {
1175 let view = &mut self.view;
1176 let done = if undo {
1177 view.editor.undo(&mut view.engine)?
1178 } else {
1179 view.editor.redo(&mut view.engine)?
1180 };
1181 self.changed |= done;
1182 }
9541183 Ok(())
9551184 }
9561185
......@@ -1001,7 +1230,8 @@ impl State {
10011230 },
10021231 );
10031232 if !self.filter.is_empty()
1004 && ui::shell::tool_button(&mut self.ui, "clear", art::CLOSE, theme.text_dim).clicked
1233 && ui::shell::tool_button(&mut self.ui, "clear", art::CLOSE, theme.text_dim, false)
1234 .clicked
10051235 {
10061236 self.filter.clear();
10071237 }
......@@ -1022,13 +1252,13 @@ impl State {
10221252 self.ui.set_focus(Some(page()));
10231253 }
10241254 }
1025 ui::shell::tool_button(&mut self.ui, "new", art::NEW_PAGE, theme.text);
1255 ui::shell::tool_button(&mut self.ui, "new", art::NEW_PAGE, theme.text, false);
10261256 let toggle = if self.pages_open {
10271257 art::SIDEBAR_COLLAPSE
10281258 } else {
10291259 art::SIDEBAR_EXPAND
10301260 };
1031 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text).clicked {
1261 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked {
10321262 self.pages_open = !self.pages_open;
10331263 }
10341264 self.ui.close();
crates/ui/examples/popups.rs+1
......@@ -343,6 +343,7 @@ impl Scene {
343343 "color",
344344 art!("icons/font-color"),
345345 Some(draw::srgb(0xe8, 0x3a, 0x30)),
346 false,
346347 );
347348 ui.close();
348349 ui.close();
crates/ui/src/shell.rs+9-3
......@@ -100,12 +100,14 @@ fn lean(height: f32) -> f32 {
100100 (height - 2.0) / 2.0
101101}
102102
103/// A square button showing `icon` tinted by `tint`; artwork in its own colours takes white.
103/// A square button showing `icon` tinted by `tint`, lit while `on`; artwork in its own
104/// colours takes white.
104105pub fn tool_button(
105106 ui: &mut Ui,
106107 part: impl Hash,
107108 icon: &'static [&'static str],
108109 tint: [f32; 4],
110 on: bool,
109111) -> Signal {
110112 let hover = ui.theme.hover();
111113 ui.leaf(
......@@ -115,6 +117,7 @@ pub fn tool_button(
115117 size: [px(TOOL), px(TOOL)],
116118 icon: Some(icon),
117119 color: Some(tint),
120 fill: on.then_some(hover),
118121 hover_fill: Some(hover),
119122 radius: 4.0,
120123 center: true,
......@@ -123,13 +126,15 @@ pub fn tool_button(
123126 )
124127}
125128
126/// A button showing `icon` with a menu arrow beside it, and optionally the colour it
127/// applies as a bar under the icon. Returns the button's and the arrow's signals.
129/// A button showing `icon`, lit while `on`, with a menu arrow beside it, and optionally
130/// the colour it applies as a bar under the icon. Returns the button's and the arrow's
131/// signals.
128132pub fn split_button(
129133 ui: &mut Ui,
130134 part: impl Hash,
131135 icon: &'static [&'static str],
132136 bar: Option<[f32; 4]>,
137 on: bool,
133138) -> [Signal; 2] {
134139 let theme = ui.theme.clone();
135140 ui.open(
......@@ -146,6 +151,7 @@ pub fn split_button(
146151 size: [px(TOOL), px(TOOL)],
147152 icon: Some(icon),
148153 color: Some(theme.text),
154 fill: on.then(|| theme.hover()),
149155 hover_fill: Some(theme.hover()),
150156 radius: 4.0,
151157 center: true,