Skip to main content

odox_ui/
toolbar.rs

1//! The row of buttons over a page being edited. DESIGN.md ยง11.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5
6use eframe::egui::{self, Key, KeyboardShortcut, Modifiers, RichText, Ui};
7use egui_richedit::{Mark, Model};
8
9use crate::i18n::t;
10use crate::{Block, FlowModel, PageEditor};
11
12/// Bold, italic, underline and strikethrough, each lit where the whole
13/// selection has it, and none of them live without a caret. Answers the mark
14/// whose button was pressed, for the editor to toggle over the model.
15pub fn marks<M: Model<Paragraph = Vec<usize>>>(
16    ui: &mut Ui,
17    editor: &PageEditor,
18    model: &M,
19) -> Option<Mark> {
20    let mut pressed = None;
21    ui.add_enabled_ui(editor.selection().is_some(), |ui| {
22        ui.horizontal(|ui| pressed = mark_buttons(ui, editor, model));
23    });
24    pressed
25}
26
27/// What was pressed on a text document's toolbar.
28#[derive(Default)]
29pub struct Pressed {
30    /// A mark's button, for the editor to toggle over the model.
31    pub mark: Option<Mark>,
32    /// A kind of paragraph's button, for the model to apply.
33    pub block: Option<Block>,
34}
35
36/// The marks, and after them the kinds of paragraph: body text, three
37/// levels of heading, a bulleted and a numbered list. Each is lit where every
38/// paragraph the selection runs over is one, and pressing a lit one takes it
39/// off.
40pub fn text(ui: &mut Ui, editor: &PageEditor, model: &FlowModel<'_>) -> Pressed {
41    let mut pressed = Pressed::default();
42    ui.add_enabled_ui(editor.selection().is_some(), |ui| {
43        ui.horizontal(|ui| {
44            pressed.mark = mark_buttons(ui, editor, model);
45            ui.separator();
46            for (block, label, name) in [
47                (Block::Body, "\u{b6}", t("Body text")),
48                (Block::Heading(1), "H1", t("Heading 1")),
49                (Block::Heading(2), "H2", t("Heading 2")),
50                (Block::Heading(3), "H3", t("Heading 3")),
51                (Block::Bullets, "\u{2022}", t("Bulleted list")),
52                (Block::Numbers, "1.", t("Numbered list")),
53            ] {
54                let lit = editor
55                    .selection()
56                    .is_some_and(|selection| model.is_block(selection, block));
57                if ui
58                    .selectable_label(lit, label)
59                    .on_hover_text(name)
60                    .clicked()
61                {
62                    pressed.block = Some(block);
63                }
64            }
65        });
66    });
67    pressed
68}
69
70/// A mark's name, and the key that toggles it with Ctrl, or Command on a Mac.
71/// Strikethrough has no shortcut that people share, so it has none.
72fn mark_facts(mark: Mark) -> (&'static str, Option<Key>) {
73    match mark {
74        Mark::Bold => (t("Bold"), Some(Key::B)),
75        Mark::Italic => (t("Italic"), Some(Key::I)),
76        Mark::Underline => (t("Underline"), Some(Key::U)),
77        Mark::Strike => (t("Strikethrough"), None),
78    }
79}
80
81fn mark_buttons<M: Model<Paragraph = Vec<usize>>>(
82    ui: &mut Ui,
83    editor: &PageEditor,
84    model: &M,
85) -> Option<Mark> {
86    let mut pressed = None;
87    for mark in Mark::ALL {
88        let lit = editor.marked(model, mark) == Some(true);
89        let (name, key) = mark_facts(mark);
90        let label = match mark {
91            Mark::Bold => RichText::new(t("B")).strong(),
92            Mark::Italic => RichText::new(t("I")).italics(),
93            Mark::Underline => RichText::new(t("U")).underline(),
94            Mark::Strike => RichText::new(t("S")).strikethrough(),
95        };
96        let hint = match key {
97            Some(key) => format!(
98                "{name} ({})",
99                ui.ctx()
100                    .format_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key))
101            ),
102            None => name.to_owned(),
103        };
104        if ui
105            .selectable_label(lit, label)
106            .on_hover_text(hint)
107            .clicked()
108        {
109            pressed = Some(mark);
110        }
111    }
112    pressed
113}
114
115/// What the Format menu asks of the page: one of the buttons' commands.
116#[derive(Clone, Copy, PartialEq, Eq)]
117pub enum Command {
118    /// Bold, italic, underline or strikethrough.
119    Mark(Mark),
120    /// A kind of paragraph.
121    Block(Block),
122}
123
124/// The kinds of paragraph, in the order the toolbar and the menu offer them.
125const BLOCKS: [Block; 6] = [
126    Block::Body,
127    Block::Heading(1),
128    Block::Heading(2),
129    Block::Heading(3),
130    Block::Bullets,
131    Block::Numbers,
132];
133
134fn block_name(block: Block) -> &'static str {
135    match block {
136        Block::Body => t("Body text"),
137        Block::Heading(1) => t("Heading 1"),
138        Block::Heading(2) => t("Heading 2"),
139        Block::Heading(_) => t("Heading 3"),
140        Block::Bullets => t("Bulleted list"),
141        Block::Numbers => t("Numbered list"),
142    }
143}
144
145/// What the Format menu draws itself from: what the page said the last time it
146/// was drawn, because a menu is drawn before the page is.
147#[derive(Default)]
148pub struct MenuState {
149    enabled: bool,
150    blocks: bool,
151    marks: Vec<Mark>,
152    kinds: Vec<Block>,
153}
154
155impl MenuState {
156    /// The state of a page over a model. `blocks` says whether the page has
157    /// kinds of paragraph to offer, which a slide's labels do not.
158    pub fn of(editor: &PageEditor, model: &FlowModel<'_>, blocks: bool) -> Self {
159        let Some(selection) = editor.selection() else {
160            return Self {
161                blocks,
162                ..Self::default()
163            };
164        };
165        Self {
166            enabled: true,
167            blocks,
168            marks: Mark::ALL
169                .into_iter()
170                .filter(|mark| editor.marked(model, *mark) == Some(true))
171                .collect(),
172            kinds: if blocks {
173                BLOCKS
174                    .into_iter()
175                    .filter(|block| model.is_block(selection, *block))
176                    .collect()
177            } else {
178                Vec::new()
179            },
180        }
181    }
182
183    /// A menu for a view that has paragraphs to offer or not, before its page
184    /// has been drawn once.
185    pub fn offering_blocks(blocks: bool) -> Self {
186        Self {
187            blocks,
188            ..Self::default()
189        }
190    }
191}
192
193/// The Format menu's entries: the marks, each with its shortcut, and where the
194/// page has them the kinds of paragraph. Lit where the whole selection is one,
195/// and not live without a caret. Answers the command chosen.
196pub fn menu(ui: &mut Ui, state: &MenuState) -> Option<Command> {
197    let mut chosen = None;
198    for mark in Mark::ALL {
199        let (name, key) = mark_facts(mark);
200        let mut button = egui::Button::new(name).selected(state.marks.contains(&mark));
201        if let Some(key) = key {
202            button = button.shortcut_text(
203                ui.ctx()
204                    .format_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key)),
205            );
206        }
207        if ui.add_enabled(state.enabled, button).clicked() {
208            chosen = Some(Command::Mark(mark));
209            ui.close();
210        }
211    }
212    if state.blocks {
213        ui.separator();
214        for block in BLOCKS {
215            let button =
216                egui::Button::new(block_name(block)).selected(state.kinds.contains(&block));
217            if ui.add_enabled(state.enabled, button).clicked() {
218                chosen = Some(Command::Block(block));
219                ui.close();
220            }
221        }
222    }
223    chosen
224}