Skip to main content

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;