Skip to main content

sqlly_datatable/
config.rs

1//! Grid-wide configuration: per-kind formatting rules, per-column overrides,
2//! and key bindings.
3//!
4//! [`GridConfig`] is cheap to clone. [`GridConfig::resolve`] and
5//! [`GridConfig::resolve_all`] turn a column index into a fully-merged
6//! [`ResolvedColumnFormat`]; the grid caches the resolved list on its state
7//! so this work does not repeat on every paint.
8
9use crate::data::ColumnKind;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum TextAlignment {
13    Left,
14    Center,
15    Right,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub enum TextCase {
20    Upper,
21    Lower,
22    Title,
23    None,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub enum TruncationBehavior {
28    Ellipsis,
29    CutOff,
30    Wrap,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34pub enum RelativeUnit {
35    Second,
36    Minute,
37    Hour,
38    Day,
39    Week,
40    Month,
41    Year,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
45pub enum ReplacementTiming {
46    BeforeFormat,
47    #[default]
48    AfterFormat,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct NumberFormat {
53    pub decimals: usize,
54    pub show_negative_red: bool,
55    pub negative_parentheses: bool,
56    pub thousands_separator: bool,
57    pub alignment: TextAlignment,
58}
59
60impl Default for NumberFormat {
61    fn default() -> Self {
62        Self {
63            decimals: 2,
64            show_negative_red: true,
65            negative_parentheses: false,
66            thousands_separator: true,
67            alignment: TextAlignment::Right,
68        }
69    }
70}
71
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct RelativeDateFormat {
74    pub units: Vec<RelativeUnit>,
75    pub max_components: usize,
76}
77
78impl Default for RelativeDateFormat {
79    fn default() -> Self {
80        Self {
81            units: vec![RelativeUnit::Year, RelativeUnit::Month, RelativeUnit::Day],
82            max_components: 1,
83        }
84    }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct DateFormat {
89    pub format: String,
90    pub timezone_offset_minutes: i32,
91    pub relative: Option<RelativeDateFormat>,
92    pub alignment: TextAlignment,
93}
94
95impl Default for DateFormat {
96    fn default() -> Self {
97        Self {
98            format: "%Y-%m-%d".into(),
99            timezone_offset_minutes: 0,
100            relative: None,
101            alignment: TextAlignment::Center,
102        }
103    }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct BooleanFormat {
108    pub true_text: String,
109    pub false_text: String,
110    pub alignment: TextAlignment,
111}
112
113impl Default for BooleanFormat {
114    fn default() -> Self {
115        Self {
116            true_text: "true".into(),
117            false_text: "false".into(),
118            alignment: TextAlignment::Center,
119        }
120    }
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct StringFormat {
125    pub case: TextCase,
126    pub max_length: Option<usize>,
127    pub truncation: TruncationBehavior,
128    pub alignment: TextAlignment,
129}
130
131impl Default for StringFormat {
132    fn default() -> Self {
133        Self {
134            case: TextCase::None,
135            max_length: None,
136            truncation: TruncationBehavior::Ellipsis,
137            alignment: TextAlignment::Left,
138        }
139    }
140}
141
142#[derive(Clone, Debug, PartialEq, Eq)]
143pub struct ReplacementRule {
144    pub find: String,
145    pub replace: String,
146}
147
148impl ReplacementRule {
149    /// Convenience constructor.
150    #[must_use]
151    pub fn new(find: impl Into<String>, replace: impl Into<String>) -> Self {
152        Self {
153            find: find.into(),
154            replace: replace.into(),
155        }
156    }
157}
158
159#[derive(Clone, Debug, Default, PartialEq, Eq)]
160pub struct ColumnOverride {
161    pub number: Option<NumberFormat>,
162    pub date: Option<DateFormat>,
163    pub boolean: Option<BooleanFormat>,
164    pub string: Option<StringFormat>,
165    pub replacements: Option<Vec<ReplacementRule>>,
166    pub replacement_timing: Option<ReplacementTiming>,
167}
168
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct ResolvedColumnFormat {
171    pub kind: ColumnKind,
172    pub number: NumberFormat,
173    pub date: DateFormat,
174    pub boolean: BooleanFormat,
175    pub string: StringFormat,
176    pub replacements: Vec<ReplacementRule>,
177    pub replacement_timing: ReplacementTiming,
178}
179
180impl ResolvedColumnFormat {
181    #[must_use]
182    pub fn alignment(&self) -> TextAlignment {
183        match self.kind {
184            ColumnKind::Integer | ColumnKind::Decimal => self.number.alignment,
185            ColumnKind::Date => self.date.alignment,
186            ColumnKind::Boolean => self.boolean.alignment,
187            ColumnKind::Text => self.string.alignment,
188            ColumnKind::None => TextAlignment::Left,
189        }
190    }
191}
192
193#[derive(Clone, Debug, PartialEq, Eq)]
194pub struct KeyBinding {
195    pub key: String,
196    pub platform: bool,
197    pub shift: bool,
198    pub alt: bool,
199    pub control: bool,
200}
201
202impl KeyBinding {
203    /// `true` iff `ks` matches this binding and carries no extra modifiers we
204    /// did not declare.
205    ///
206    /// For example, `Cmd+C` matches `copy`; `Cmd+Alt+C` does not unless `alt`
207    /// is `true` on the binding. This avoids the previous footgun where
208    /// `Cmd+Alt+C` would still satisfy a binding that only declared `Cmd+C`.
209    pub fn matches(&self, ks: &gpui::Keystroke) -> bool {
210        let required = self.platform || self.shift || self.alt || self.control;
211        let actual =
212            ks.modifiers.platform || ks.modifiers.shift || ks.modifiers.alt || ks.modifiers.control;
213        // If the binding requires nothing at all, only match when the user
214        // pressed exactly that key with no modifiers.
215        if !required {
216            return self.key == ks.key && !actual;
217        }
218        self.key == ks.key
219            && self.platform == ks.modifiers.platform
220            && self.shift == ks.modifiers.shift
221            && self.alt == ks.modifiers.alt
222            && self.control == ks.modifiers.control
223    }
224}
225
226#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct KeyBindings {
228    pub select_all: KeyBinding,
229    pub copy: KeyBinding,
230    pub copy_with_headers: KeyBinding,
231    pub page_up: KeyBinding,
232    pub page_down: KeyBinding,
233    pub context_menu_modifier_control: bool,
234    pub context_menu_modifier_alt: bool,
235}
236
237impl Default for KeyBindings {
238    fn default() -> Self {
239        Self {
240            select_all: KeyBinding {
241                key: "a".into(),
242                platform: true,
243                shift: false,
244                alt: false,
245                control: false,
246            },
247            copy: KeyBinding {
248                key: "c".into(),
249                platform: true,
250                shift: false,
251                alt: false,
252                control: false,
253            },
254            copy_with_headers: KeyBinding {
255                key: "c".into(),
256                platform: true,
257                shift: true,
258                alt: false,
259                control: false,
260            },
261            page_up: KeyBinding {
262                key: "pageup".into(),
263                platform: false,
264                shift: false,
265                alt: false,
266                control: false,
267            },
268            page_down: KeyBinding {
269                key: "pagedown".into(),
270                platform: false,
271                shift: false,
272                alt: false,
273                control: false,
274            },
275            context_menu_modifier_control: true,
276            context_menu_modifier_alt: false,
277        }
278    }
279}
280
281#[derive(Clone, Debug, PartialEq, Eq)]
282pub struct GridConfig {
283    pub key_bindings: KeyBindings,
284    pub default_number: NumberFormat,
285    pub default_date: DateFormat,
286    pub default_boolean: BooleanFormat,
287    pub default_string: StringFormat,
288    pub default_replacements: Vec<ReplacementRule>,
289    pub replacement_timing: ReplacementTiming,
290    pub column_overrides: Vec<ColumnOverride>,
291}
292
293impl Default for GridConfig {
294    fn default() -> Self {
295        Self {
296            key_bindings: KeyBindings::default(),
297            default_number: NumberFormat::default(),
298            default_date: DateFormat::default(),
299            default_boolean: BooleanFormat::default(),
300            default_string: StringFormat::default(),
301            default_replacements: vec![],
302            replacement_timing: ReplacementTiming::AfterFormat,
303            column_overrides: vec![],
304        }
305    }
306}
307
308impl GridConfig {
309    /// Resolve the format for a single column. Returns a freshly-merged
310    /// [`ResolvedColumnFormat`] every call; the grid state caches the result.
311    #[must_use]
312    pub fn resolve(&self, col_idx: usize, kind: ColumnKind) -> ResolvedColumnFormat {
313        let o = self.column_overrides.get(col_idx);
314        ResolvedColumnFormat {
315            kind,
316            number: o.and_then(|o| o.number).unwrap_or(self.default_number),
317            date: o
318                .and_then(|o| o.date.clone())
319                .unwrap_or_else(|| self.default_date.clone()),
320            boolean: o
321                .and_then(|o| o.boolean.clone())
322                .unwrap_or_else(|| self.default_boolean.clone()),
323            string: o
324                .and_then(|o| o.string.clone())
325                .unwrap_or_else(|| self.default_string.clone()),
326            replacements: o
327                .and_then(|o| o.replacements.clone())
328                .unwrap_or_else(|| self.default_replacements.clone()),
329            replacement_timing: o
330                .and_then(|o| o.replacement_timing)
331                .unwrap_or(self.replacement_timing),
332        }
333    }
334
335    /// Resolve formats for every column. Used during state initialization and
336    /// when the config changes.
337    #[must_use]
338    pub fn resolve_all(&self, columns: &[crate::data::Column]) -> Vec<ResolvedColumnFormat> {
339        columns
340            .iter()
341            .enumerate()
342            .map(|(i, c)| self.resolve(i, c.kind))
343            .collect()
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use gpui::Keystroke;
351
352    fn ks(key: &str, platform: bool, shift: bool, alt: bool, control: bool) -> Keystroke {
353        Keystroke {
354            key: key.into(),
355            modifiers: gpui::Modifiers {
356                platform,
357                shift,
358                alt,
359                control,
360                function: false,
361            },
362            ..Default::default()
363        }
364    }
365
366    #[test]
367    fn resolve_uses_defaults_without_override() {
368        let cfg = GridConfig::default();
369        let cols = vec![
370            crate::data::Column::new("a", ColumnKind::Text, 80.0),
371            crate::data::Column::new("b", ColumnKind::Integer, 80.0),
372        ];
373        let resolved = cfg.resolve_all(&cols);
374        assert_eq!(resolved.len(), 2);
375        assert_eq!(resolved[0].kind, ColumnKind::Text);
376        assert_eq!(resolved[1].kind, ColumnKind::Integer);
377        assert_eq!(resolved[0].number.alignment, TextAlignment::Right);
378        assert_eq!(resolved[0].string.alignment, TextAlignment::Left);
379    }
380
381    #[test]
382    fn resolve_uses_per_column_override() {
383        let cfg = GridConfig {
384            column_overrides: vec![
385                ColumnOverride {
386                    number: Some(NumberFormat {
387                        decimals: 4,
388                        ..NumberFormat::default()
389                    }),
390                    ..Default::default()
391                },
392                ColumnOverride::default(),
393            ],
394            ..GridConfig::default()
395        };
396        let cols = vec![
397            crate::data::Column::new("a", ColumnKind::Decimal, 80.0),
398            crate::data::Column::new("b", ColumnKind::Decimal, 80.0),
399        ];
400        let resolved = cfg.resolve_all(&cols);
401        assert_eq!(resolved[0].number.decimals, 4);
402        assert_eq!(resolved[1].number.decimals, 2);
403    }
404
405    #[test]
406    fn key_binding_matches_exact_modifier_set() {
407        let binding = KeyBinding {
408            key: "c".into(),
409            platform: true,
410            shift: false,
411            alt: false,
412            control: false,
413        };
414        assert!(binding.matches(&ks("c", true, false, false, false)));
415        // Adding extra modifiers (Alt) should NOT match a binding that didn't request it.
416        assert!(!binding.matches(&ks("c", true, false, true, false)));
417        assert!(!binding.matches(&ks("c", true, false, false, true)));
418        // Wrong key never matches.
419        assert!(!binding.matches(&ks("x", true, false, false, false)));
420    }
421
422    #[test]
423    fn key_binding_with_no_required_modifier_only_matches_bare_key() {
424        let binding = KeyBinding {
425            key: "pagedown".into(),
426            platform: false,
427            shift: false,
428            alt: false,
429            control: false,
430        };
431        assert!(binding.matches(&ks("pagedown", false, false, false, false)));
432        assert!(!binding.matches(&ks("pagedown", true, false, false, false)));
433    }
434
435    #[test]
436    fn key_binding_with_alt_true_accepts_alt_modifier() {
437        let binding = KeyBinding {
438            key: "c".into(),
439            platform: true,
440            shift: false,
441            alt: true,
442            control: false,
443        };
444        assert!(binding.matches(&ks("c", true, false, true, false)));
445        assert!(!binding.matches(&ks("c", true, false, false, false)));
446    }
447}