teksilo_widgets/code_editor/config.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Injected, language-agnostic editing configuration.
5//!
6//! Everything a code editor does that *looks* language-specific is a mechanism
7//! here plus a value the application supplies. The editor knows how to toggle a
8//! line comment; it does not know that Rust uses `//`. It knows how to close a
9//! bracket; it does not know that Rust has `<>` in generics and Python does not.
10//!
11//! This is the difference between a widget and an IDE, and it is why there is
12//! no `Language` enum anywhere in this module: adding one would mean every new
13//! language is a change to Teksilo rather than a value in the caller's code.
14
15/// How a line's leading indentation is written.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum IndentStyle {
18 /// `width` spaces per indent level.
19 Spaces(u8),
20 /// One tab character per level, rendered `width` columns wide.
21 Tabs { width: u8 },
22}
23
24impl IndentStyle {
25 /// The text one indent level inserts.
26 pub fn unit(&self) -> String {
27 match self {
28 Self::Spaces(n) => " ".repeat(*n as usize),
29 Self::Tabs { .. } => "\t".to_string(),
30 }
31 }
32
33 /// How many columns one level occupies on screen. Both styles need this:
34 /// spaces to know how many to strip on dedent, tabs to render the stop.
35 pub fn width(&self) -> u8 {
36 match self {
37 Self::Spaces(n) => *n,
38 Self::Tabs { width } => *width,
39 }
40 }
41}
42
43impl Default for IndentStyle {
44 /// Four spaces. Chosen because it is the majority default across editors
45 /// and is unambiguous on every renderer; a project that disagrees says so.
46 fn default() -> Self {
47 Self::Spaces(4)
48 }
49}
50
51/// A pair of characters the editor treats as opening and closing delimiters.
52///
53/// Used for auto-closing and for match highlighting. The application declares
54/// the set, because the *same* character means different things per language:
55/// `<` is a bracket in a generic parameter list and a less-than sign in
56/// arithmetic, and only the caller knows which document this is.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct BracketPair {
59 pub open: char,
60 pub close: char,
61}
62
63impl BracketPair {
64 pub const fn new(open: char, close: char) -> Self {
65 Self { open, close }
66 }
67}
68
69/// The three pairs that are structural in essentially every bracketed
70/// language. A convenience starting point, not a default — an editor with no
71/// configured pairs simply does no bracket handling, which is correct for
72/// prose or a log.
73pub const COMMON_BRACKETS: &[BracketPair] = &[
74 BracketPair::new('(', ')'),
75 BracketPair::new('[', ']'),
76 BracketPair::new('{', '}'),
77];
78
79/// Editing behaviour the code editor applies, all supplied by the application.
80#[derive(Debug, Clone)]
81pub struct CodeConfig {
82 /// How indentation is written and how wide a level is.
83 pub indent: IndentStyle,
84 /// Whether Enter carries the current line's leading whitespace onto the
85 /// new line.
86 pub auto_indent: bool,
87 /// Token that starts a line comment (`"//"`, `"#"`, `"--"`, `";"`).
88 /// `None` disables `CodeCommand::ToggleLineComment` entirely rather than
89 /// guessing.
90 pub line_comment: Option<String>,
91 /// Delimiter pairs for auto-closing and match highlighting. Empty disables
92 /// both.
93 pub brackets: Vec<BracketPair>,
94 /// Whether typing an opening delimiter inserts its closing partner.
95 pub auto_close_brackets: bool,
96 /// Whether the delimiter matching the caret's is highlighted.
97 pub match_brackets: bool,
98}
99
100impl Default for CodeConfig {
101 /// The language-neutral default: indent and auto-indent work (they need no
102 /// language knowledge), while comment toggling and bracket handling stay
103 /// **off** because they cannot be right without the application saying what
104 /// the tokens are. A wrong guess here is worse than nothing — inserting `//`
105 /// into a Python file corrupts it silently.
106 fn default() -> Self {
107 Self {
108 indent: IndentStyle::default(),
109 auto_indent: true,
110 line_comment: None,
111 brackets: Vec::new(),
112 auto_close_brackets: false,
113 match_brackets: false,
114 }
115 }
116}
117
118impl CodeConfig {
119 /// The closing partner for `open`, if it is a configured opening delimiter.
120 pub fn closing_for(&self, open: char) -> Option<char> {
121 self.brackets
122 .iter()
123 .find(|p| p.open == open)
124 .map(|p| p.close)
125 }
126
127 /// The opening partner for `close`, if it is a configured closing delimiter.
128 pub fn opening_for(&self, close: char) -> Option<char> {
129 self.brackets
130 .iter()
131 .find(|p| p.close == close)
132 .map(|p| p.open)
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn spaces_indent_unit_is_its_width() {
142 assert_eq!(IndentStyle::Spaces(4).unit(), " ");
143 assert_eq!(IndentStyle::Spaces(2).unit(), " ");
144 }
145
146 /// A tab is one character however wide it renders — conflating the two is
147 /// the classic indent bug (deleting 4 columns eats 4 tabs).
148 #[test]
149 fn tab_indent_unit_is_one_character_regardless_of_width() {
150 assert_eq!(IndentStyle::Tabs { width: 8 }.unit(), "\t");
151 assert_eq!(IndentStyle::Tabs { width: 8 }.width(), 8);
152 }
153
154 /// The default must not pretend to know the language. Guessing `//` would
155 /// silently corrupt a Python file the first time someone hit Ctrl+/.
156 #[test]
157 fn the_default_config_makes_no_language_assumptions() {
158 let c = CodeConfig::default();
159 assert!(c.line_comment.is_none(), "must not guess a comment token");
160 assert!(c.brackets.is_empty(), "must not guess bracket pairs");
161 assert!(!c.auto_close_brackets);
162 assert!(!c.match_brackets);
163 // These two need no language knowledge, so they are on.
164 assert!(c.auto_indent);
165 assert_eq!(c.indent, IndentStyle::Spaces(4));
166 }
167
168 #[test]
169 fn bracket_lookup_resolves_both_directions() {
170 let c = CodeConfig {
171 brackets: COMMON_BRACKETS.to_vec(),
172 ..CodeConfig::default()
173 };
174 assert_eq!(c.closing_for('('), Some(')'));
175 assert_eq!(c.opening_for('}'), Some('{'));
176 assert_eq!(c.closing_for('<'), None, "unconfigured pairs stay unknown");
177 }
178}