Skip to main content

strop_engine/editor/
indent.rs

1//! `:tab-size` / `:indent-style` (0051 R08): per-buffer indent
2//! overrides and the compact selector. Overrides win over detection
3//! and config, survive reloads and config refreshes, and never rewrite
4//! existing buffer bytes — they change rendering and new indentation.
5
6use strop_picker::{IndentChoice, Item, Kind, Payload, Picker};
7
8use super::document::IndentSource;
9use super::Editor;
10use crate::config::IndentStyle;
11
12impl Editor {
13    /// One effective setting for source editing, projected rows and carets.
14    pub fn indentation_at(
15        &self,
16        document: strop_core::id::DocumentId,
17        byte: usize,
18    ) -> super::document::Indent {
19        let source = self.indent_target_at(document, byte).unwrap_or(document);
20        self.doc(source).indent
21    }
22
23    pub fn tab_width_for_path(&self, path: &std::path::Path) -> usize {
24        let target = crate::files::FileTarget::Local(self.picker_path(path));
25        self.docs
26            .iter()
27            .find(|(_, document)| document.matches_target(&target))
28            .map_or(self.config.tab_size, |(_, document)| document.indent.width)
29    }
30
31    fn indent_target_at(
32        &self,
33        document: strop_core::id::DocumentId,
34        byte: usize,
35    ) -> Option<strop_core::id::DocumentId> {
36        if let Some((source, _)) = self.source_position(document, byte) {
37            return Some(source);
38        }
39        let view = self.docs.get(document)?;
40        let collection = self.collections.get(&document)?;
41        match collection.rows.get(view.buf.line_of(byte)) {
42            Some(super::collections::CollectionRow::CardTop(index)) => collection
43                .excerpts
44                .get(*index)
45                .map(|excerpt| excerpt.source),
46            _ => None,
47        }
48    }
49
50    fn indent_command_target(&mut self) -> Option<strop_core::id::DocumentId> {
51        let target = self.indent_target_at(self.current(), self.head());
52        if target.is_none() {
53            self.message = "place the caret in a source excerpt to change indentation".into();
54        }
55        target
56    }
57
58    /// `:tab-size` — bare opens the selector; `N` sets a per-buffer
59    /// width override (1–16, anything else refused visibly); `auto`
60    /// clears the override and re-resolves from detection/config.
61    pub(crate) fn tab_size_command(&mut self, arg: &str) {
62        let arg = arg.trim();
63        if arg.is_empty() {
64            self.open_tab_size_picker();
65            return;
66        }
67        let Some(document) = self.indent_command_target() else {
68            return;
69        };
70        if arg.eq_ignore_ascii_case("auto") {
71            self.set_width_override(document, None);
72            return;
73        }
74        match arg.parse::<usize>() {
75            Ok(width)
76                if (crate::config::TAB_SIZE_MIN..=crate::config::TAB_SIZE_MAX).contains(&width) =>
77            {
78                self.set_width_override(document, Some(width));
79            }
80            _ => {
81                self.message = format!(
82                    "tab-size takes a width {}–{} or auto, not {arg:?}",
83                    crate::config::TAB_SIZE_MIN,
84                    crate::config::TAB_SIZE_MAX,
85                );
86            }
87        }
88    }
89
90    /// `:indent-style spaces|tabs|auto` — the style side of the same
91    /// override layer; bare opens the selector, which lists the styles.
92    pub(crate) fn indent_style_command(&mut self, arg: &str) {
93        if arg.trim().is_empty() {
94            self.open_tab_size_picker();
95            return;
96        }
97        let Some(document) = self.indent_command_target() else {
98            return;
99        };
100        match arg.trim() {
101            "spaces" => self.set_style_override(document, Some(IndentStyle::Spaces)),
102            "tabs" => self.set_style_override(document, Some(IndentStyle::Tabs)),
103            "auto" => self.set_style_override(document, None),
104            other => {
105                self.message = format!("indent-style takes spaces, tabs or auto, not {other:?}")
106            }
107        }
108    }
109
110    /// The modeline's indent segment: `Spaces:4` / `Tabs:4` (0051 R08).
111    pub fn indent_label(&self) -> String {
112        self.indentation_at(self.current(), self.head()).label()
113    }
114
115    /// The compact `:tab-size` selector (0051 R08): common widths, the
116    /// style choices, Auto for both, and a pinned custom row that
117    /// validates the typed number on accept. The current effective
118    /// setting and its provenance are marked on the matching rows.
119    pub(crate) fn open_tab_size_picker(&mut self) {
120        let Some(document) = self.indent_command_target() else {
121            return;
122        };
123        let indent = self.doc(document).indent;
124        let current = |on: bool, source: IndentSource| {
125            if on {
126                format!(" — current ({})", source.label())
127            } else {
128                String::new()
129            }
130        };
131        let mut items: Vec<Item> = [2usize, 3, 4, 8]
132            .into_iter()
133            .map(|width| Item {
134                badge: Some(width.to_string()),
135                text: format!(
136                    "spaces per indent{}",
137                    current(
138                        indent.style == IndentStyle::Spaces && indent.width == width,
139                        indent.width_source,
140                    )
141                ),
142                payload: Payload::IndentChoice(IndentChoice::Width(width)),
143            })
144            .collect();
145        items.push(Item {
146            badge: None,
147            text: format!(
148                "auto width — detect/configure{}",
149                current(
150                    indent.width_source != IndentSource::Manual,
151                    indent.width_source
152                )
153            ),
154            payload: Payload::IndentChoice(IndentChoice::AutoWidth),
155        });
156        items.push(Item {
157            badge: None,
158            text: format!(
159                "style: spaces{}",
160                current(indent.style == IndentStyle::Spaces, indent.style_source)
161            ),
162            payload: Payload::IndentChoice(IndentChoice::Spaces),
163        });
164        items.push(Item {
165            badge: None,
166            text: format!(
167                "style: tabs{}",
168                current(indent.style == IndentStyle::Tabs, indent.style_source)
169            ),
170            payload: Payload::IndentChoice(IndentChoice::Tabs),
171        });
172        items.push(Item {
173            badge: None,
174            text: format!(
175                "style: auto — detect/configure{}",
176                current(
177                    indent.style_source != IndentSource::Manual,
178                    indent.style_source
179                )
180            ),
181            payload: Payload::IndentChoice(IndentChoice::AutoStyle),
182        });
183        // Pinned tail: filtering never hides the custom row, and the
184        // typed text becomes the width on accept (validated there).
185        items.push(Item {
186            badge: None,
187            text: format!(
188                "custom width… — type {}–{}, enter",
189                crate::config::TAB_SIZE_MIN,
190                crate::config::TAB_SIZE_MAX
191            ),
192            payload: Payload::IndentChoice(IndentChoice::CustomWidth),
193        });
194        let mut picker = Picker::new(Kind::TabSize, items, false);
195        picker.pinned_tail = 1;
196        let mut glue = super::picker::PickerGlue::diagnostics(picker);
197        glue.indent_target = Some(document);
198        self.set_picker(glue);
199    }
200
201    /// A selector row's choice. `draft` is the typed filter text — the
202    /// custom row's width candidate, validated here (visible refusal).
203    pub(crate) fn accept_indent_choice(
204        &mut self,
205        document: strop_core::id::DocumentId,
206        choice: IndentChoice,
207        draft: &str,
208    ) {
209        match choice {
210            IndentChoice::Width(width) => self.set_width_override(document, Some(width)),
211            IndentChoice::AutoWidth => self.set_width_override(document, None),
212            IndentChoice::Spaces => self.set_style_override(document, Some(IndentStyle::Spaces)),
213            IndentChoice::Tabs => self.set_style_override(document, Some(IndentStyle::Tabs)),
214            IndentChoice::AutoStyle => self.set_style_override(document, None),
215            IndentChoice::CustomWidth => match draft.parse::<usize>() {
216                Ok(width)
217                    if (crate::config::TAB_SIZE_MIN..=crate::config::TAB_SIZE_MAX)
218                        .contains(&width) =>
219                {
220                    self.set_width_override(document, Some(width));
221                }
222                _ if draft.is_empty() => {
223                    self.message = format!(
224                        "type a width {}–{} first, then enter",
225                        crate::config::TAB_SIZE_MIN,
226                        crate::config::TAB_SIZE_MAX,
227                    );
228                }
229                _ => {
230                    self.message = format!(
231                        "tab-size takes a width {}–{}, not {draft:?}",
232                        crate::config::TAB_SIZE_MIN,
233                        crate::config::TAB_SIZE_MAX,
234                    );
235                }
236            },
237        }
238    }
239
240    fn set_width_override(&mut self, document: strop_core::id::DocumentId, width: Option<usize>) {
241        let Some(doc) = self.docs.get_mut(document) else {
242            self.message = "indentation source was closed".into();
243            return;
244        };
245        doc.indent_override.width = width;
246        self.resolve_indent_for(document);
247        self.message = self.indent_status(document);
248    }
249
250    fn set_style_override(
251        &mut self,
252        document: strop_core::id::DocumentId,
253        style: Option<IndentStyle>,
254    ) {
255        let Some(doc) = self.docs.get_mut(document) else {
256            self.message = "indentation source was closed".into();
257            return;
258        };
259        doc.indent_override.style = style;
260        self.resolve_indent_for(document);
261        self.message = self.indent_status(document);
262    }
263
264    /// The post-command feedback: effective setting with per-side
265    /// provenance — `Spaces:8 (width manual, style detected)`.
266    fn indent_status(&self, document: strop_core::id::DocumentId) -> String {
267        let indent = self.doc(document).indent;
268        format!(
269            "{}: {} (width {}, style {})",
270            self.doc(document).label(&self.cwd),
271            indent.label(),
272            indent.width_source.label(),
273            indent.style_source.label(),
274        )
275    }
276}