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,