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