Skip to main content

teksilo_widgets/rich_text/
lists.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The list toolbar: which kind of list the caret is in, and the toggle a
5//! "Bullets" or "Numbering" button performs.
6//!
7//! [`RichTextEditor::insert_list`] only ever *makes* a list, so a toolbar built
8//! on it can neither show that the caret already sits in one nor take the list
9//! away again. A word processor's two list buttons do both, and do it by kind,
10//! not by exact style: a "Bullets" button is pressed in a circle-bullet list as
11//! much as in a disc one, and pressing it there takes the paragraph out rather
12//! than re-making the list. [`list_style_is_ordered`] is that classification,
13//! and [`toggle_list`] is the press:
14//!
15//! * In a list of the same kind, the paragraphs leave it. Without a selection
16//!   that is the caret's paragraph; with one, every selected paragraph that is
17//!   in a list.
18//! * In a list of the other kind, the list changes kind. Without a selection
19//!   the whole list switches, keeping its depth, the way a word processor
20//!   renumbers a bulleted list when Numbering is pressed in any of its items.
21//!   With one, the selected paragraphs become one list of the new kind.
22//! * Outside a list, the paragraphs become one, as [`insert_list`] does.
23//!
24//! Every branch is one undo step.
25//!
26//! [`RichTextEditor::insert_list`]: super::RichTextEditor::insert_list
27//! [`insert_list`]: super::RichTextEditor::insert_list
28
29use teksilo_text::text_document::{ListFormat, ListStyle};
30
31use super::state::EditorState;
32
33/// Whether `style` numbers its items (decimal, alphabetic, roman) rather than
34/// marking them with a bullet (disc, circle, square).
35///
36/// The kind a toolbar's two list buttons stand for. Exhaustive on purpose: a
37/// style added to the document model has to be given a kind here before
38/// anything compiles.
39pub fn list_style_is_ordered(style: &ListStyle) -> bool {
40    match style {
41        ListStyle::Disc | ListStyle::Circle | ListStyle::Square => false,
42        ListStyle::Decimal
43        | ListStyle::LowerAlpha
44        | ListStyle::UpperAlpha
45        | ListStyle::LowerRoman
46        | ListStyle::UpperRoman => true,
47    }
48}
49
50/// The style a toggle creates for `ordered`: decimal numbers or disc bullets,
51/// the same pair [`insert_list`](super::RichTextEditor::insert_list) uses.
52fn default_style(ordered: bool) -> ListStyle {
53    if ordered {
54        ListStyle::Decimal
55    } else {
56        ListStyle::Disc
57    }
58}
59
60/// The style of the list the caret's paragraph belongs to, or `None` outside a
61/// list.
62pub(super) fn current_list_style(st: &EditorState) -> Option<ListStyle> {
63    st.cursor.current_list().map(|list| list.style())
64}
65
66/// Give the caret's whole list `style`, keeping its depth. Does nothing
67/// outside a list.
68pub(super) fn set_list_style(st: &EditorState, style: ListStyle) {
69    if st.cursor.current_list().is_none() {
70        return;
71    }
72    let _ = st.cursor.set_current_list_format(&ListFormat {
73        style: Some(style),
74        ..Default::default()
75    });
76}
77
78/// The press of a "Bullets" (`ordered == false`) or "Numbering"
79/// (`ordered == true`) button. See the module documentation for what each case
80/// does.
81pub(super) fn toggle_list(st: &EditorState, ordered: bool) {
82    let current = current_list_style(st);
83    let has_selection = st.cursor.has_selection();
84    st.cursor.begin_edit_block();
85    match current {
86        Some(style) if list_style_is_ordered(&style) == ordered => {
87            if has_selection {
88                remove_selected_blocks_from_lists(st);
89            } else {
90                let _ = st.cursor.remove_current_block_from_list();
91            }
92        }
93        Some(_) if !has_selection => set_list_style(st, default_style(ordered)),
94        // In a list of the other kind with a selection, or outside any list:
95        // the selected paragraphs (or the caret's) become one new list. Blocks
96        // leaving another list for it take that list away with them.
97        _ => {
98            let _ = st.cursor.create_list(default_style(ordered));
99        }
100    }
101    st.cursor.end_edit_block();
102}
103
104/// Take every paragraph the selection touches out of whatever list it is in.
105///
106/// The selection's start is read as a character index and its end as a caret,
107/// the reading [`create_list`](teksilo_text::text_document::TextCursor::create_list)
108/// gives a selection: a selection ending at the end of a paragraph does not
109/// reach into the next one.
110fn remove_selected_blocks_from_lists(st: &EditorState) {
111    let anchor = st.cursor.anchor();
112    let position = st.cursor.position();
113    let (start, end) = (anchor.min(position), anchor.max(position));
114    let blocks = st.document.blocks_in_range(start, end - start);
115    for block in blocks {
116        if block.list().is_some() {
117            let _ = st.cursor.remove_block_from_list(block.id());
118        }
119    }
120}