From 263f6a1b7979ef2640fe4d7cfba53c4b9974b442 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 22:22:07 -0700 Subject: [PATCH] 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 --- .../snowbound/assets/icons/align-center.svg | 3 + crates/snowbound/assets/icons/align-right.svg | 3 + crates/snowbound/assets/icons/superscript.svg | 4 + crates/snowbound/src/art.rs | 3 + crates/snowbound/src/main.rs | 326 +++++++++++++++--- crates/ui/examples/popups.rs | 1 + crates/ui/src/shell.rs | 12 +- 7 files changed, 301 insertions(+), 51 deletions(-) create mode 100644 crates/snowbound/assets/icons/align-center.svg create mode 100644 crates/snowbound/assets/icons/align-right.svg create mode 100644 crates/snowbound/assets/icons/superscript.svg diff --git a/crates/snowbound/assets/icons/align-center.svg b/crates/snowbound/assets/icons/align-center.svg new file mode 100644 index 0000000000000000000000000000000000000000..105eeb2f93cb805da32a691416323355db6c2aae --- /dev/null +++ b/crates/snowbound/assets/icons/align-center.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/align-right.svg b/crates/snowbound/assets/icons/align-right.svg new file mode 100644 index 0000000000000000000000000000000000000000..e2861e370e4111668cb4f6f1da4eea30e6d64285 --- /dev/null +++ b/crates/snowbound/assets/icons/align-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/superscript.svg b/crates/snowbound/assets/icons/superscript.svg new file mode 100644 index 0000000000000000000000000000000000000000..22799d1a70a9a01393433cec3fd3a59f57973367 --- /dev/null +++ b/crates/snowbound/assets/icons/superscript.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index c30f6e2f0e24df28234f6838e34fb0313e20b241..c2584468a6c03d6b81186e02779023cb5b04e81d 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -6,6 +6,8 @@ macro_rules! art { }; } +pub const ALIGN_CENTER: &[&str] = art!("icons/align-center"); +pub const ALIGN_RIGHT: &[&str] = art!("icons/align-right"); pub const ALIGN_LEFT: &[&str] = art!("icons/align-left"); pub const ATTACHMENT: &[&str] = art!("icons/attachment"); pub const BOLD: &[&str] = art!("icons/bold"); @@ -30,6 +32,7 @@ pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse"); pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand"); pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough"); pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); +pub const SUPERSCRIPT: &[&str] = art!("icons/superscript"); pub const TABLE: &[&str] = art!("icons/table"); pub const UNDERLINE: &[&str] = art!("icons/underline"); pub const UNDO: &[&str] = art!("icons/undo"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 1e6c526eb701802bd1d8fd5b7f967b6e2317f76b..f0f6dc1ad786ad90aa3fd24797a46458b422d086 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -9,7 +9,10 @@ use canvas::{ editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, layout::TextEngine, }; -use canvas::{gpu::tag_sources, outline::TagIcon}; +use canvas::{ + gpu::{colorref, tag_sources}, + outline::TagIcon, +}; use draw::Renderer; use onestore::ExGuid; use onestore::document::Format; @@ -42,6 +45,22 @@ const FRAME: f32 = 6.0; /// macOS rounds windows' corners by 10 pt; the page's corners share their centres. const ROUNDING: f32 = 10.0 - FRAME; const PAGE_LIST: f32 = 240.0; +/// Font sizes the size box offers, OneNote's list in points. +const SIZES: [f32; 17] = [ + 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, + 72.0, +]; +/// OneNote's highlight colours, COLORREF. +const HIGHLIGHTS: &[u32] = &[ + 0x00ffff, 0x00ff00, 0xffff00, 0xff00ff, 0xff0000, 0x0000ff, 0x800000, 0x808000, 0x008000, + 0x800080, 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000, +]; +/// Office's theme and standard font colours, COLORREF. +const FONT_COLORS: [u32; 20] = [ + 0xffffff, 0x000000, 0xe1ecee, 0x7d491f, 0xbd814f, 0x4d50c0, 0x59bb9b, 0xa26480, 0xc6ac4b, + 0x4696f7, 0x0000c0, 0x0000ff, 0x00c0ff, 0x00ffff, 0x50d092, 0x50b000, 0xf0b000, 0xc07000, + 0x602000, 0xa03070, +]; /// Height of a page tab's row, whose tab leaves `ROW_GAP` below it so tabs stand apart /// while the gaps still take the pointer. const ROW: f32 = 29.0; @@ -235,6 +254,8 @@ struct State { filter: String, /// Whether the page list is shown beside the page. pages_open: bool, + /// Installed font families, for the font box; listing them is slow. + fonts: Vec, /// The application's icon at the display's scale, for the title bar. app_icon: Option, commands: Vec, @@ -426,6 +447,14 @@ impl State { eprintln!("Using {family} for the interface"); } ui.set_focus(Some(page())); + let mut fonts: Vec = engine + .fonts + .collection + .family_names() + .map(String::from) + .collect(); + fonts.sort_unstable_by_key(|name| name.to_lowercase()); + fonts.dedup(); let state = Self { window, redraw: Arc::new(Redraw(proxy.clone())).into(), @@ -447,6 +476,7 @@ impl State { session, filter: String::new(), pages_open: true, + fonts, app_icon: macos::app_icon((16.0 * dpr).round() as u32), commands: Vec::new(), changed: false, @@ -813,8 +843,27 @@ impl State { /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home /// ribbon groups them. fn toolbar(&mut self, theme: &Theme) -> Result<(), Box> { + use canvas::editor::{Alignment, FormatState, Formatting, NoteTag, Toggle}; + // A focused picture has no text to show a format for. + let state = self + .view + .editor + .format_state() + .unwrap_or_else(|_| FormatState { + toggles: Vec::new(), + font: None, + font_size: None, + alignment: None, + bullets: false, + numbering: false, + tags: Vec::new(), + }); + let fonts = &self.fonts; let ui = &mut self.ui; let text = theme.text; + let on = |toggle| state.toggles.contains(&toggle); + let mut command = None; + let mut history = None; ui.open( "toolbar", Spec { @@ -826,65 +875,232 @@ impl State { }, ); group(ui, "history", |ui| { - ui::shell::tool_button(ui, "undo", art::UNDO, text); - ui::shell::tool_button(ui, "redo", art::REDO, text); + if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked { + history = Some(true); + } + if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked { + history = Some(false); + } }); divider(ui, "history", theme); + let popup = |name: &str| Id::ROOT.child(("popup", name)); + let font = state.font.clone().unwrap_or_default(); + let size = state + .font_size + .map_or(String::new(), |size| format!("{size}")); group(ui, "text", |ui| { row(ui, 0, |ui| { - ui::shell::combo(ui, "font", "Calibri", 120.0); - ui::shell::combo(ui, "size", "11", 44.0); - ui::shell::split_button(ui, "bullets", art::BULLETS, None); - ui::shell::split_button(ui, "numbering", art::NUMBERING, None); - ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text); + let combo = ui.id("font"); + if ui::shell::combo(ui, "font", &font, 120.0).clicked { + ui.open_popup(popup("font")); + } + let items: Vec<_> = fonts + .iter() + .map(|name| ui::popup::Item { + text: name, + checked: *name == font, + ..Default::default() + }) + .collect(); + let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, popup("font"), anchor, &items, Some(&font)) + { + command = Some(Formatting::Font(fonts[index].clone())); + } + let combo = ui.id("size"); + if ui::shell::combo(ui, "size", &size, 44.0).clicked { + ui.open_popup(popup("size")); + } + let labels = SIZES.map(|size| format!("{size}")); + let items: Vec<_> = labels + .iter() + .map(|label| ui::popup::Item { + text: label, + checked: *label == size, + ..Default::default() + }) + .collect(); + let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size)) + { + command = Some(Formatting::FontSize(SIZES[index])); + } + let bullets = + ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets); + let numbering = + ui::shell::split_button(ui, "numbering", art::NUMBERING, None, state.numbering); + if bullets.iter().any(|signal| signal.clicked) { + command = Some(Formatting::Bullets); + } + if numbering.iter().any(|signal| signal.clicked) { + command = Some(Formatting::Numbering); + } + if ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text, false).clicked { + command = Some(Formatting::Clear); + } }); row(ui, 1, |ui| { - for (part, icon) in [ - ("bold", art::BOLD), - ("italic", art::ITALIC), - ("underline", art::UNDERLINE), - ("strikethrough", art::STRIKETHROUGH), + for (part, icon, toggle) in [ + ("bold", art::BOLD, Toggle::Bold), + ("italic", art::ITALIC, Toggle::Italic), + ("underline", art::UNDERLINE, Toggle::Underline), + ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough), ] { - ui::shell::tool_button(ui, part, icon, text); + if ui::shell::tool_button(ui, part, icon, text, on(toggle)).clicked { + command = Some(Formatting::Toggle(toggle)); + } } - ui::shell::split_button(ui, "script", art::SUBSCRIPT, None); - ui::shell::split_button( + let script = ui.id("script"); + let superscript = on(Toggle::Superscript); + let [button, menu] = ui::shell::split_button( ui, - "highlight", - art::HIGHLIGHTER, - Some(draw::srgb(0xff, 0xff, 0x00)), + "script", + if superscript { + art::SUPERSCRIPT + } else { + art::SUBSCRIPT + }, + None, + on(Toggle::Subscript) || superscript, ); - ui::shell::split_button( - ui, - "color", - art::FONT_COLOR, - Some(draw::srgb(0xe8, 0x3a, 0x30)), - ); - ui::shell::tool_button(ui, "outdent", art::OUTDENT, text); - ui::shell::tool_button(ui, "indent", art::INDENT, text); - ui::shell::split_button(ui, "align", art::ALIGN_LEFT, None); + if button.clicked { + command = Some(Formatting::Toggle(if superscript { + Toggle::Superscript + } else { + Toggle::Subscript + })); + } + if menu.clicked { + ui.open_popup(popup("script")); + } + let items = [ + ui::popup::Item { + text: "Subscript", + icon: Some(art::SUBSCRIPT), + shortcut: "โŒ˜=", + checked: on(Toggle::Subscript), + ..Default::default() + }, + ui::popup::Item { + text: "Superscript", + icon: Some(art::SUPERSCRIPT), + shortcut: "โ‡งโŒ˜=", + checked: superscript, + ..Default::default() + }, + ]; + let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, popup("script"), anchor, &items, None) { + command = Some(Formatting::Toggle( + [Toggle::Subscript, Toggle::Superscript][index], + )); + } + for (part, icon, swatches, columns, none, default) in [ + ( + "highlight", + art::HIGHLIGHTER, + HIGHLIGHTS, + 5, + "No colour", + 0x00ffff, + ), + ( + "color", + art::FONT_COLOR, + &FONT_COLORS[..], + 10, + "Automatic", + 0x3a3ae8, + ), + ] { + let split = ui.id(part); + let [button, menu] = + ui::shell::split_button(ui, part, icon, Some(colorref(default)), false); + let paint = |color| { + if part == "highlight" { + Formatting::Highlight(color) + } else { + Formatting::Color(color) + } + }; + if button.clicked { + command = Some(paint(Some(default))); + } + if menu.clicked { + ui.open_popup(popup(part)); + } + let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); + let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); + if let Some(chosen) = + ui::popup::colors(ui, popup(part), anchor, none, &colors, columns) + { + command = Some(paint(chosen.and_then(|chosen| { + swatches + .iter() + .zip(&colors) + .find(|(_, color)| **color == chosen) + .map(|(stored, _)| *stored) + }))); + } + } + if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked { + command = Some(Formatting::Outdent); + } + if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked { + command = Some(Formatting::Indent); + } + let align = ui.id("align"); + let alignments = [ + (Alignment::Left, "Align left", art::ALIGN_LEFT), + (Alignment::Center, "Centre", art::ALIGN_CENTER), + (Alignment::Right, "Align right", art::ALIGN_RIGHT), + ]; + let current = alignments + .iter() + .find(|(alignment, ..)| Some(*alignment) == state.alignment) + .unwrap_or(&alignments[0]); + let [button, menu] = ui::shell::split_button(ui, "align", current.2, None, false); + if button.clicked || menu.clicked { + ui.open_popup(popup("align")); + } + let items = alignments.map(|(alignment, name, icon)| ui::popup::Item { + text: name, + icon: Some(icon), + checked: Some(alignment) == state.alignment, + ..Default::default() + }); + let anchor = ui::Anchor::Below(ui.rect(align).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, popup("align"), anchor, &items, None) { + command = Some(Formatting::Align(alignments[index].0)); + } }); }); divider(ui, "text", theme); - let tags = [ - tag_sources(TagIcon::CheckBox { checked: false }), - art::TAG_STAR, - tag_sources(TagIcon::Question), - art::TAG_REMEMBER, - art::TAG_DEFINITION, - art::TAG_HIGHLIGHT, - art::TAG_CONTACT, - art::TAG_ADDRESS, - art::TAG_PHONE, + let tags: [(&[&str], Option); 9] = [ + ( + tag_sources(TagIcon::CheckBox { checked: false }), + Some(NoteTag::ToDo), + ), + (art::TAG_STAR, None), + (tag_sources(TagIcon::Question), Some(NoteTag::Question)), + (art::TAG_REMEMBER, None), + (art::TAG_DEFINITION, None), + (art::TAG_HIGHLIGHT, None), + (art::TAG_CONTACT, None), + (art::TAG_ADDRESS, None), + (art::TAG_PHONE, None), ]; group(ui, "tags", |ui| { for (index, tags) in tags.chunks(5).enumerate() { row(ui, index, |ui| { - for (column, icon) in tags.iter().enumerate() { - ui::shell::tool_button(ui, column, icon, [1.0; 4]); + for (column, (icon, tag)) in tags.iter().enumerate() { + let lit = tag.is_some_and(|tag| state.tags.contains(&tag)); + if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked { + command = tag.map(Formatting::Tag); + } } if index == 1 { - ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text); + ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, false); } }); } @@ -898,7 +1114,7 @@ impl State { ("file", art::ATTACHMENT), ("link", art::LINK), ] { - ui::shell::tool_button(ui, part, icon, text); + ui::shell::tool_button(ui, part, icon, text, false); } }); row(ui, 1, |ui| { @@ -907,7 +1123,7 @@ impl State { ("time", art::CLOCK), ("equation", art::EQUATION), ] { - ui::shell::tool_button(ui, part, icon, text); + ui::shell::tool_button(ui, part, icon, text, false); } }); }); @@ -923,7 +1139,7 @@ impl State { let mut chosen = None; group(ui, "zoom", |ui| { row(ui, 0, |ui| { - if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text).clicked { + if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text, false).clicked { chosen = Some(zoom / 1.1); } let level = ui.leaf( @@ -941,7 +1157,7 @@ impl State { if level.clicked { chosen = Some(1.0); } - if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text).clicked { + if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text, false).clicked { chosen = Some(zoom * 1.1); } }); @@ -951,6 +1167,19 @@ impl State { let response = self.view.set_zoom(zoom)?; self.respond(response); } + if let Some(command) = command { + let response = self.view.format(command)?; + self.respond(response); + } + if let Some(undo) = history { + let view = &mut self.view; + let done = if undo { + view.editor.undo(&mut view.engine)? + } else { + view.editor.redo(&mut view.engine)? + }; + self.changed |= done; + } Ok(()) } @@ -1001,7 +1230,8 @@ impl State { }, ); if !self.filter.is_empty() - && ui::shell::tool_button(&mut self.ui, "clear", art::CLOSE, theme.text_dim).clicked + && ui::shell::tool_button(&mut self.ui, "clear", art::CLOSE, theme.text_dim, false) + .clicked { self.filter.clear(); } @@ -1022,13 +1252,13 @@ impl State { self.ui.set_focus(Some(page())); } } - ui::shell::tool_button(&mut self.ui, "new", art::NEW_PAGE, theme.text); + ui::shell::tool_button(&mut self.ui, "new", art::NEW_PAGE, theme.text, false); let toggle = if self.pages_open { art::SIDEBAR_COLLAPSE } else { art::SIDEBAR_EXPAND }; - if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text).clicked { + if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked { self.pages_open = !self.pages_open; } self.ui.close(); diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs index 6594dcd62f64420f5af63db7fac3d6624f1ac956..b5c9214b740277976efd3a8308f9b3a77a73d16e 100644 --- a/crates/ui/examples/popups.rs +++ b/crates/ui/examples/popups.rs @@ -343,6 +343,7 @@ impl Scene { "color", art!("icons/font-color"), Some(draw::srgb(0xe8, 0x3a, 0x30)), + false, ); ui.close(); ui.close(); diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 6c63c6fe9b818c0878cda721fd292364603d8466..f9218421faa528e91dd6845de8fd8bdb005f9413 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -100,12 +100,14 @@ fn lean(height: f32) -> f32 { (height - 2.0) / 2.0 } -/// A square button showing `icon` tinted by `tint`; artwork in its own colours takes white. +/// A square button showing `icon` tinted by `tint`, lit while `on`; artwork in its own +/// colours takes white. pub fn tool_button( ui: &mut Ui, part: impl Hash, icon: &'static [&'static str], tint: [f32; 4], + on: bool, ) -> Signal { let hover = ui.theme.hover(); ui.leaf( @@ -115,6 +117,7 @@ pub fn tool_button( size: [px(TOOL), px(TOOL)], icon: Some(icon), color: Some(tint), + fill: on.then_some(hover), hover_fill: Some(hover), radius: 4.0, center: true, @@ -123,13 +126,15 @@ pub fn tool_button( ) } -/// A button showing `icon` with a menu arrow beside it, and optionally the colour it -/// applies as a bar under the icon. Returns the button's and the arrow's signals. +/// A button showing `icon`, lit while `on`, with a menu arrow beside it, and optionally +/// the colour it applies as a bar under the icon. Returns the button's and the arrow's +/// signals. pub fn split_button( ui: &mut Ui, part: impl Hash, icon: &'static [&'static str], bar: Option<[f32; 4]>, + on: bool, ) -> [Signal; 2] { let theme = ui.theme.clone(); ui.open( @@ -146,6 +151,7 @@ pub fn split_button( size: [px(TOOL), px(TOOL)], icon: Some(icon), color: Some(theme.text), + fill: on.then(|| theme.hover()), hover_fill: Some(theme.hover()), radius: 4.0, center: true, -- 2.54.0