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