agg_gui/widgets/rich_text/commands.rs
1//! Rich-text **command engine** — applies editor commands to a [`RichDoc`]
2//! over a selection [`DocRange`].
3//!
4//! Sits on top of [`super::model`]'s structural primitives. Inline commands
5//! (bold/italic/color/…) split runs at the exact range edges and mutate only
6//! the covered runs, using egui/Word toggle semantics: if the *entire* range
7//! already carries an attribute, the toggle removes it, otherwise it sets it.
8//! Block commands (align/list/indent) apply to every block the range touches.
9//!
10//! Undo/redo is intentionally **not** modelled here — phase 2 wraps the whole
11//! document in the existing [`crate::undo`] `Undoer`, so commands stay pure
12//! forward mutations.
13
14use super::model::{Block, DocPos, DocRange, InlineStyle, ListKind, RichDoc};
15use crate::color::Color;
16use crate::widgets::text_area::TextHAlign;
17
18/// Maximum indent depth an [`RichCommand::Indent`] will reach.
19pub const MAX_INDENT: u8 = 8;
20
21/// A single editor command. Deliberately excludes undo/redo (see module docs).
22#[derive(Clone, Debug, PartialEq)]
23pub enum RichCommand {
24 /// Toggle bold over the selection (Word/egui toggle semantics).
25 ToggleBold,
26 /// Toggle italic over the selection.
27 ToggleItalic,
28 /// Toggle underline over the selection.
29 ToggleUnderline,
30 /// Toggle strikethrough over the selection.
31 ToggleStrikethrough,
32 /// Set the font family of the selected runs.
33 SetFontFamily(String),
34 /// Set the font size (points) of the selected runs.
35 SetFontSize(f64),
36 /// Set the text colour of the selected runs.
37 SetTextColor(Color),
38 /// Set (or clear, with `None`) the highlight colour of the selected runs.
39 SetHighlight(Option<Color>),
40 /// Set the horizontal alignment of every touched block.
41 SetAlign(TextHAlign),
42 /// Set (or toggle off) the list decoration of every touched block.
43 SetList(ListKind),
44 /// Increase the indent depth of every touched block (capped at [`MAX_INDENT`]).
45 Indent,
46 /// Decrease the indent depth of every touched block (floored at 0).
47 Outdent,
48}
49
50/// Per-attribute summary of the styles across a selection, for toolbar state.
51///
52/// Each field is `None` when the selected runs **disagree** on that attribute
53/// ("mixed"), or `Some(value)` when they all share `value`. For the
54/// inherit-able attributes the value is itself an `Option` (`None` = "all
55/// inherit the widget default", `Some(x)` = "all set to `x`").
56#[derive(Clone, Debug, Default, PartialEq)]
57pub struct CommonStyle {
58 /// Shared bold state, or `None` when the selection is mixed.
59 pub bold: Option<bool>,
60 /// Shared italic state, or `None` when mixed.
61 pub italic: Option<bool>,
62 /// Shared underline state, or `None` when mixed.
63 pub underline: Option<bool>,
64 /// Shared strikethrough state, or `None` when mixed.
65 pub strikethrough: Option<bool>,
66 /// Shared font family (inner `None` = all inherit default), or `None` when mixed.
67 pub font_family: Option<Option<String>>,
68 /// Shared font size (inner `None` = all inherit default), or `None` when mixed.
69 pub font_size: Option<Option<f64>>,
70 /// Shared text colour (inner `None` = all inherit default), or `None` when mixed.
71 pub text_color: Option<Option<Color>>,
72 /// Shared highlight colour (inner `None` = none), or `None` when mixed.
73 pub highlight: Option<Option<Color>>,
74 /// Block-level alignment shared by every block the selection touches, or
75 /// `None` when they disagree (mixed). Drives the toolbar's L/C/R alignment
76 /// radio group. Only populated by [`range_common_style`] — the inline-only
77 /// [`CommonStyle::of_style`] path leaves it `None`.
78 pub align: Option<TextHAlign>,
79 /// Block-level list decoration shared by every touched block, or `None`
80 /// when mixed. Drives the ordered/bullet toggles: `Some(ListKind::None)`
81 /// means "all plain paragraphs", so neither list button is active.
82 pub list: Option<ListKind>,
83}
84
85impl CommonStyle {
86 /// A `CommonStyle` in which every attribute agrees with `style` — the
87 /// summary of a single uniform run. Used by the editor to report the
88 /// *pending* caret style (a toggled format awaiting the next keystroke) to
89 /// the toolbar, where no run yet carries it.
90 pub fn of_style(style: &InlineStyle) -> Self {
91 Self::from_style(style)
92 }
93
94 /// Seed a `CommonStyle` where every attribute agrees with `style`.
95 fn from_style(style: &InlineStyle) -> Self {
96 Self {
97 bold: Some(style.bold),
98 italic: Some(style.italic),
99 underline: Some(style.underline),
100 strikethrough: Some(style.strikethrough),
101 font_family: Some(style.font_family.clone()),
102 font_size: Some(style.font_size),
103 text_color: Some(style.text_color),
104 highlight: Some(style.highlight),
105 // Block-level attributes are unknown from a run's inline style;
106 // `range_common_style` folds them in afterwards from the blocks.
107 align: None,
108 list: None,
109 }
110 }
111
112 /// Fold the block-level align/list attributes of every block `range`
113 /// touches into this summary. Called by [`range_common_style`] after the
114 /// inline attributes are seeded, so an all-agree selection reports a
115 /// concrete `Some(_)` and a mixed one reports `None`.
116 ///
117 /// `pub(crate)` so the editor's collapsed-caret + pending-style path can
118 /// fold the caret block's align/list into a [`CommonStyle::of_style`]
119 /// summary — block state is independent of the armed inline pending style.
120 pub(crate) fn merge_blocks(&mut self, doc: &RichDoc, range: DocRange) {
121 let (a, b) = range.ordered();
122 let mut first = true;
123 for bi in a.block..=b.block {
124 let Some(block) = doc.blocks.get(bi) else {
125 continue;
126 };
127 if first {
128 self.align = Some(block.align);
129 self.list = Some(block.list);
130 first = false;
131 } else {
132 fold(&mut self.align, block.align);
133 fold(&mut self.list, block.list);
134 }
135 }
136 }
137
138 /// Fold another run's `style` in: any attribute that now disagrees becomes
139 /// `None` (mixed).
140 fn merge(&mut self, style: &InlineStyle) {
141 fold(&mut self.bold, style.bold);
142 fold(&mut self.italic, style.italic);
143 fold(&mut self.underline, style.underline);
144 fold(&mut self.strikethrough, style.strikethrough);
145 fold(&mut self.font_family, style.font_family.clone());
146 fold(&mut self.font_size, style.font_size);
147 fold(&mut self.text_color, style.text_color);
148 fold(&mut self.highlight, style.highlight);
149 }
150}
151
152/// If `slot` currently agrees on a value that differs from `value`, collapse it
153/// to `None` (mixed).
154fn fold<T: PartialEq>(slot: &mut Option<T>, value: T) {
155 if let Some(existing) = slot {
156 if *existing != value {
157 *slot = None;
158 }
159 }
160}
161
162// ---------------------------------------------------------------------------
163// Style introspection
164// ---------------------------------------------------------------------------
165
166/// The style that a newly-typed character at `pos` would inherit: the style of
167/// the character immediately *before* `pos` (egui/Word caret semantics), or the
168/// first run's style at the very start of a block, or the default in an empty
169/// block.
170pub fn style_at(doc: &RichDoc, pos: DocPos) -> InlineStyle {
171 let Some(block) = doc.blocks.get(pos.block) else {
172 return InlineStyle::default();
173 };
174 if block.runs.is_empty() {
175 return InlineStyle::default();
176 }
177 if pos.byte == 0 {
178 return block.runs[0].style.clone();
179 }
180 // Style of the char just before `pos`.
181 let target = pos.byte - 1;
182 let mut acc = 0usize;
183 for run in &block.runs {
184 let len = run.text.len();
185 if target < acc + len {
186 return run.style.clone();
187 }
188 acc += len;
189 }
190 block
191 .runs
192 .last()
193 .map(|r| r.style.clone())
194 .unwrap_or_default()
195}
196
197/// Summarise the styles across `range`. An empty range reports the style at
198/// its caret position (via [`style_at`]).
199pub fn range_common_style(doc: &RichDoc, range: DocRange) -> CommonStyle {
200 let (a, b) = range.ordered();
201 let mut cs = if a == b {
202 CommonStyle::from_style(&style_at(doc, a))
203 } else {
204 let styles = collect_styles_in_range(doc, range);
205 let mut iter = styles.iter();
206 match iter.next() {
207 Some(first) => {
208 let mut cs = CommonStyle::from_style(first);
209 for s in iter {
210 cs.merge(s);
211 }
212 cs
213 }
214 // Range covers only empty blocks — fall back to the caret style.
215 None => CommonStyle::from_style(&style_at(doc, a)),
216 }
217 };
218 // Block attributes (align/list) always summarise every block the range
219 // touches, independent of the inline-run folding above.
220 cs.merge_blocks(doc, range);
221 cs
222}
223
224/// Collect (clones of) the styles of every run that intersects `range`.
225fn collect_styles_in_range(doc: &RichDoc, range: DocRange) -> Vec<InlineStyle> {
226 let (a, b) = range.ordered();
227 let mut out = Vec::new();
228 for bi in a.block..=b.block {
229 let Some(block) = doc.blocks.get(bi) else {
230 continue;
231 };
232 let lo = if bi == a.block { a.byte } else { 0 };
233 let hi = if bi == b.block {
234 b.byte
235 } else {
236 block.text_len()
237 };
238 if lo >= hi {
239 continue;
240 }
241 let mut acc = 0usize;
242 for run in &block.runs {
243 let len = run.text.len();
244 let (rs, re) = (acc, acc + len);
245 if len > 0 && re > lo && rs < hi {
246 out.push(run.style.clone());
247 }
248 acc = re;
249 }
250 }
251 out
252}
253
254// ---------------------------------------------------------------------------
255// Command application
256// ---------------------------------------------------------------------------
257
258/// Apply `cmd` to `doc` over `range`.
259pub fn apply_command(doc: &mut RichDoc, range: DocRange, cmd: &RichCommand) {
260 match cmd {
261 RichCommand::ToggleBold => toggle_bool(doc, range, |cs| cs.bold, |s, v| s.bold = v),
262 RichCommand::ToggleItalic => toggle_bool(doc, range, |cs| cs.italic, |s, v| s.italic = v),
263 RichCommand::ToggleUnderline => {
264 toggle_bool(doc, range, |cs| cs.underline, |s, v| s.underline = v)
265 }
266 RichCommand::ToggleStrikethrough => {
267 toggle_bool(doc, range, |cs| cs.strikethrough, |s, v| s.strikethrough = v)
268 }
269 RichCommand::SetFontFamily(family) => {
270 for_each_run_in_range(doc, range, |s| s.font_family = Some(family.clone()))
271 }
272 RichCommand::SetFontSize(size) => {
273 for_each_run_in_range(doc, range, |s| s.font_size = Some(*size))
274 }
275 RichCommand::SetTextColor(color) => {
276 for_each_run_in_range(doc, range, |s| s.text_color = Some(*color))
277 }
278 RichCommand::SetHighlight(color) => {
279 for_each_run_in_range(doc, range, |s| s.highlight = *color)
280 }
281 RichCommand::SetAlign(align) => for_each_block_in_range(doc, range, |b| b.align = *align),
282 RichCommand::SetList(kind) => apply_set_list(doc, range, *kind),
283 RichCommand::Indent => {
284 for_each_block_in_range(doc, range, |b| b.indent = (b.indent + 1).min(MAX_INDENT))
285 }
286 RichCommand::Outdent => {
287 for_each_block_in_range(doc, range, |b| b.indent = b.indent.saturating_sub(1))
288 }
289 }
290}
291
292/// Toggle a boolean attribute using egui/Word semantics: if the whole range
293/// already has it set, clear it; otherwise set it everywhere.
294fn toggle_bool(
295 doc: &mut RichDoc,
296 range: DocRange,
297 read: impl Fn(&CommonStyle) -> Option<bool>,
298 write: impl Fn(&mut InlineStyle, bool),
299) {
300 let all_set = read(&range_common_style(doc, range)) == Some(true);
301 let target = !all_set;
302 for_each_run_in_range(doc, range, |s| write(s, target));
303}
304
305/// `SetList` toggles back to `None` when every touched block is already that
306/// (non-`None`) kind; otherwise it sets the kind.
307fn apply_set_list(doc: &mut RichDoc, range: DocRange, kind: ListKind) {
308 let (a, b) = range.ordered();
309 let all_same = kind != ListKind::None
310 && (a.block..=b.block)
311 .filter_map(|bi| doc.blocks.get(bi))
312 .all(|blk| blk.list == kind);
313 let target = if all_same { ListKind::None } else { kind };
314 for_each_block_in_range(doc, range, |blk| blk.list = target);
315}
316
317/// Split runs at the range edges within each intersecting block, then apply
318/// `f` to the style of every run inside the range. Empty ranges are a no-op
319/// (there is nothing to style until phase 2 adds a pending-format caret).
320fn for_each_run_in_range(doc: &mut RichDoc, range: DocRange, mut f: impl FnMut(&mut InlineStyle)) {
321 let (a, b) = range.ordered();
322 if a == b {
323 return;
324 }
325 for bi in a.block..=b.block {
326 let Some(block) = doc.blocks.get_mut(bi) else {
327 continue;
328 };
329 let lo = if bi == a.block { a.byte } else { 0 };
330 let hi = if bi == b.block {
331 b.byte
332 } else {
333 block.text_len()
334 };
335 if lo >= hi {
336 continue;
337 }
338 let start = block.ensure_boundary(lo);
339 let end = block.ensure_boundary(hi);
340 for run in &mut block.runs[start..end] {
341 f(&mut run.style);
342 }
343 block.normalize();
344 }
345}
346
347/// Apply `f` to every block the range intersects (inclusive of both endpoints'
348/// blocks), even when the range is empty (a single block).
349fn for_each_block_in_range(doc: &mut RichDoc, range: DocRange, mut f: impl FnMut(&mut Block)) {
350 let (a, b) = range.ordered();
351 for bi in a.block..=b.block {
352 if let Some(block) = doc.blocks.get_mut(bi) {
353 f(block);
354 }
355 }
356}
357
358#[cfg(test)]
359#[path = "commands_tests.rs"]
360mod commands_tests;