Skip to main content

twrite_gpui/
config.rs

1use gpui::{Font, Pixels, SharedString, px};
2
3/// Layout and visual settings for the editor canvas.
4#[derive(Debug, Clone)]
5pub struct EditorConfig {
6    /// Whether to render line numbers in the left gutter.
7    pub line_numbers: bool,
8    /// Vertical line height in pixels.
9    pub line_height: Pixels,
10    /// Text font size in pixels.
11    pub font_size: Pixels,
12    /// Number of spaces per tab indentation.
13    pub tab_size: usize,
14    /// Whether to highlight the background of the active cursor line.
15    pub highlight_active_line: bool,
16    /// Default cursor shape: true for block, false for line/bar.
17    pub block_cursor: bool,
18    /// Whether the cursor should blink when focused.
19    pub cursor_blink: bool,
20    /// Whether right-click opens the expandable context menu.
21    pub context_menu: bool,
22    /// Whether the built-in edit rows (Undo/Redo/Cut/Copy/Paste/Delete/Select All)
23    /// lead the context menu. Hooks always append after them.
24    pub show_default_menu_items: bool,
25    /// Whether to soft-wrap lines at the viewport boundary.
26    pub line_wrap: bool,
27    /// Base font family override (`None` auto-selects, see below).
28    ///
29    /// When unset, the editor probes [`Self::platform_monospace_candidates`]
30    /// at paint time and uses the first family with bold + italic faces. An
31    /// explicitly set family is trusted verbatim (still probed, so
32    /// `Editor::face_availability` stays truthful). Missing faces fall back
33    /// silently at the OS level, which is why auto-select exists.
34    pub font_family: Option<SharedString>,
35    /// Font family for `Code` spans (`None` reuses the base family).
36    pub code_font_family: Option<SharedString>,
37    /// Markdown WYSIWYG and syntax configuration.
38    #[cfg(feature = "markdown")]
39    pub markdown: twrite_core::markdown::MarkdownConfig,
40}
41
42impl Default for EditorConfig {
43    fn default() -> Self {
44        Self {
45            line_numbers: false,
46            line_height: px(22.0),
47            font_size: px(16.0),
48            tab_size: 4,
49            highlight_active_line: false,
50            block_cursor: false,
51            cursor_blink: true,
52            context_menu: true,
53            show_default_menu_items: true,
54            line_wrap: true,
55            font_family: None,
56            code_font_family: None,
57            #[cfg(feature = "markdown")]
58            markdown: twrite_core::markdown::MarkdownConfig::default(),
59        }
60    }
61}
62
63impl EditorConfig {
64    /// Ordered monospace fallback families for font auto-select.
65    ///
66    /// Ordered by likelihood of shipping full (regular/bold/italic/bold-italic)
67    /// faces: a partial set (e.g. regular+bold only) can never satisfy emphasis,
68    /// so completeness outranks name recognition.
69    pub fn platform_monospace_candidates() -> Vec<SharedString> {
70        if cfg!(target_os = "macos") {
71            vec!["Menlo".into(), "Monaco".into(), "Courier New".into()]
72        } else if cfg!(target_os = "windows") {
73            vec![
74                "Consolas".into(),
75                "Cascadia Mono".into(),
76                "Courier New".into(),
77            ]
78        } else {
79            vec![
80                "Liberation Mono".into(),
81                "DejaVu Sans Mono".into(),
82                "Noto Sans Mono".into(),
83                "monospace".into(),
84            ]
85        }
86    }
87
88    /// Candidate families for auto-select: the explicit family alone when set,
89    /// otherwise the platform list.
90    pub fn font_candidates(&self) -> Vec<SharedString> {
91        match &self.font_family {
92            Some(family) => vec![family.clone()],
93            None => Self::platform_monospace_candidates(),
94        }
95    }
96
97    /// Picks the first candidate with bold + italic faces (else the first with
98    /// either, else `None`). Pure to stay headless-testable; callers pass a
99    /// probe comparing resolved `FontId`s.
100    pub fn pick_family(
101        candidates: &[SharedString],
102        mut probe: impl FnMut(&str) -> (bool, bool),
103    ) -> Option<&SharedString> {
104        let mut partial = None;
105        for candidate in candidates {
106            match probe(candidate.as_ref()) {
107                (true, true) => return Some(candidate),
108                (false, false) => {}
109                _ => {
110                    if partial.is_none() {
111                        partial = Some(candidate);
112                    }
113                }
114            }
115        }
116        partial
117    }
118
119    /// Resolves the base font: explicit family, else auto-selected family, else host.
120    pub fn base_font(&self, host: &Font, selected: Option<&SharedString>) -> Font {
121        let mut font = host.clone();
122        if let Some(family) = self.font_family.as_ref().or(selected) {
123            font.family = family.clone();
124        }
125        font
126    }
127
128    /// Resolves the font for `Code` spans: explicit code family, else whatever
129    /// [`Self::base_font`] resolves (so code follows auto-select by default).
130    pub fn code_font(&self, host: &Font, selected: Option<&SharedString>) -> Font {
131        let mut font = self.base_font(host, selected);
132        if let Some(family) = &self.code_font_family {
133            font.family = family.clone();
134        }
135        font
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn probe_for(
144        full: Vec<&'static str>,
145        partial: Vec<&'static str>,
146    ) -> impl FnMut(&str) -> (bool, bool) {
147        move |name: &str| {
148            if full.contains(&name) {
149                (true, true)
150            } else if partial.contains(&name) {
151                (true, false)
152            } else {
153                (false, false)
154            }
155        }
156    }
157
158    #[test]
159    fn pick_family_prefers_full_faces() {
160        let candidates: Vec<SharedString> = vec!["A".into(), "B".into(), "C".into()];
161        let picked = EditorConfig::pick_family(&candidates, probe_for(vec!["B"], vec!["A"]));
162        assert_eq!(picked.map(|s| s.as_ref()), Some("B"));
163    }
164
165    #[test]
166    fn pick_family_falls_back_to_partial_then_none() {
167        let candidates: Vec<SharedString> = vec!["A".into(), "B".into()];
168        let picked = EditorConfig::pick_family(&candidates, probe_for(vec![], vec!["B"]));
169        assert_eq!(picked.map(|s| s.as_ref()), Some("B"));
170
171        let picked = EditorConfig::pick_family(&candidates, probe_for(vec![], vec![]));
172        assert!(picked.is_none());
173    }
174
175    #[test]
176    fn explicit_candidates_shortcircuit_to_single_family() {
177        let config = EditorConfig {
178            font_family: Some("Mine".into()),
179            ..EditorConfig::default()
180        };
181        assert_eq!(config.font_candidates(), vec![SharedString::from("Mine")]);
182    }
183
184    #[test]
185    fn base_font_precedence_is_explicit_selected_host() {
186        use gpui::Font;
187        // gpui 0.2.2 removed `Font::default()`; the old default was
188        // `font(".SystemUIFont")`, preserved here.
189        let host: Font = gpui::font(".SystemUIFont");
190        let selected: SharedString = "Selected".into();
191        let config = EditorConfig::default();
192
193        assert_eq!(
194            config.base_font(&host, Some(&selected)).family.as_ref(),
195            "Selected"
196        );
197        assert_eq!(
198            config.base_font(&host, None).family.as_ref(),
199            host.family.as_ref()
200        );
201
202        let config = EditorConfig {
203            font_family: Some("Explicit".into()),
204            ..EditorConfig::default()
205        };
206        assert_eq!(
207            config.base_font(&host, Some(&selected)).family.as_ref(),
208            "Explicit"
209        );
210        // Code follows the selected base unless explicitly overridden.
211        assert_eq!(
212            config.code_font(&host, Some(&selected)).family.as_ref(),
213            "Explicit"
214        );
215        let config = EditorConfig::default();
216        assert_eq!(
217            config.code_font(&host, Some(&selected)).family.as_ref(),
218            "Selected"
219        );
220    }
221
222    #[test]
223    fn cursor_blink_defaults_to_true() {
224        let config = EditorConfig::default();
225        assert!(config.cursor_blink);
226    }
227}