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/// How a cell with no value ([`crate::data::CellValue::None`]) is displayed.
143/// The grid-wide default is set via [`GridConfig::default_null`]; individual
144/// columns can override it through [`ColumnOverride::null`]. Columns without
145/// an override use the grid default, and callers that never set
146/// `default_null` get the built-in default: italic `NULL` over a distinctive
147/// background (`GridTheme::null_bg` / `null_fg`).
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct NullFormat {
150    /// Placeholder text shown in the cell.
151    pub text: String,
152    /// Render the placeholder in italics.
153    pub italic: bool,
154    /// Fill the cell with the theme's `null_bg` behind the placeholder.
155    pub background: bool,
156}
157
158impl Default for NullFormat {
159    fn default() -> Self {
160        Self {
161            text: "NULL".into(),
162            italic: true,
163            background: true,
164        }
165    }
166}
167
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct ReplacementRule {
170    pub find: String,
171    pub replace: String,
172}
173
174impl ReplacementRule {
175    /// Convenience constructor.
176    #[must_use]
177    pub fn new(find: impl Into<String>, replace: impl Into<String>) -> Self {
178        Self {
179            find: find.into(),
180            replace: replace.into(),
181        }
182    }
183}
184
185#[derive(Clone, Debug, Default, PartialEq, Eq)]
186pub struct ColumnOverride {
187    pub number: Option<NumberFormat>,
188    pub date: Option<DateFormat>,
189    pub boolean: Option<BooleanFormat>,
190    pub string: Option<StringFormat>,
191    pub null: Option<NullFormat>,
192    pub replacements: Option<Vec<ReplacementRule>>,
193    pub replacement_timing: Option<ReplacementTiming>,
194}
195
196#[derive(Clone, Debug, PartialEq, Eq)]
197pub struct ResolvedColumnFormat {
198    pub kind: ColumnKind,
199    pub number: NumberFormat,
200    pub date: DateFormat,
201    pub boolean: BooleanFormat,
202    pub string: StringFormat,
203    pub null: NullFormat,
204    pub replacements: Vec<ReplacementRule>,
205    pub replacement_timing: ReplacementTiming,
206}
207
208impl ResolvedColumnFormat {
209    #[must_use]
210    pub fn alignment(&self) -> TextAlignment {
211        match self.kind {
212            ColumnKind::Integer | ColumnKind::Decimal => self.number.alignment,
213            ColumnKind::Date => self.date.alignment,
214            ColumnKind::Boolean => self.boolean.alignment,
215            ColumnKind::Text => self.string.alignment,
216            ColumnKind::None => TextAlignment::Left,
217        }
218    }
219}
220
221#[derive(Clone, Debug, PartialEq, Eq)]
222pub struct KeyBinding {
223    pub key: String,
224    pub platform: bool,
225    pub shift: bool,
226    pub alt: bool,
227    pub control: bool,
228}
229
230impl KeyBinding {
231    /// `true` iff `ks` matches this binding and carries no extra modifiers we
232    /// did not declare.
233    ///
234    /// For example, `Cmd+C` matches `copy`; `Cmd+Alt+C` does not unless `alt`
235    /// is `true` on the binding. This avoids the previous footgun where
236    /// `Cmd+Alt+C` would still satisfy a binding that only declared `Cmd+C`.
237    pub fn matches(&self, ks: &gpui::Keystroke) -> bool {
238        let required = self.platform || self.shift || self.alt || self.control;
239        let actual =
240            ks.modifiers.platform || ks.modifiers.shift || ks.modifiers.alt || ks.modifiers.control;
241        // If the binding requires nothing at all, only match when the user
242        // pressed exactly that key with no modifiers.
243        if !required {
244            return self.key == ks.key && !actual;
245        }
246        self.key == ks.key
247            && self.platform == ks.modifiers.platform
248            && self.shift == ks.modifiers.shift
249            && self.alt == ks.modifiers.alt
250            && self.control == ks.modifiers.control
251    }
252}
253
254#[derive(Clone, Debug, PartialEq, Eq)]
255pub struct KeyBindings {
256    pub select_all: KeyBinding,
257    pub copy: KeyBinding,
258    pub copy_with_headers: KeyBinding,
259    pub page_up: KeyBinding,
260    pub page_down: KeyBinding,
261    pub context_menu_modifier_control: bool,
262    pub context_menu_modifier_alt: bool,
263}
264
265impl Default for KeyBindings {
266    fn default() -> Self {
267        Self {
268            select_all: KeyBinding {
269                key: "a".into(),
270                platform: true,
271                shift: false,
272                alt: false,
273                control: false,
274            },
275            copy: KeyBinding {
276                key: "c".into(),
277                platform: true,
278                shift: false,
279                alt: false,
280                control: false,
281            },
282            copy_with_headers: KeyBinding {
283                key: "c".into(),
284                platform: true,
285                shift: true,
286                alt: false,
287                control: false,
288            },
289            page_up: KeyBinding {
290                key: "pageup".into(),
291                platform: false,
292                shift: false,
293                alt: false,
294                control: false,
295            },
296            page_down: KeyBinding {
297                key: "pagedown".into(),
298                platform: false,
299                shift: false,
300                alt: false,
301                control: false,
302            },
303            context_menu_modifier_control: true,
304            context_menu_modifier_alt: false,
305        }
306    }
307}
308
309#[derive(Clone, Debug, PartialEq, Eq)]
310pub struct GridConfig {
311    pub key_bindings: KeyBindings,
312    pub default_number: NumberFormat,
313    pub default_date: DateFormat,
314    pub default_boolean: BooleanFormat,
315    pub default_string: StringFormat,
316    /// Grid-wide display for cells with no value; per-column override via
317    /// [`ColumnOverride::null`].
318    pub default_null: NullFormat,
319    pub default_replacements: Vec<ReplacementRule>,
320    pub replacement_timing: ReplacementTiming,
321    pub column_overrides: Vec<ColumnOverride>,
322}
323
324impl Default for GridConfig {
325    fn default() -> Self {
326        Self {
327            key_bindings: KeyBindings::default(),
328            default_number: NumberFormat::default(),
329            default_date: DateFormat::default(),
330            default_boolean: BooleanFormat::default(),
331            default_string: StringFormat::default(),
332            default_null: NullFormat::default(),
333            default_replacements: vec![],
334            replacement_timing: ReplacementTiming::AfterFormat,
335            column_overrides: vec![],
336        }
337    }
338}
339
340impl GridConfig {
341    /// Resolve the format for a single column. Returns a freshly-merged
342    /// [`ResolvedColumnFormat`] every call; the grid state caches the result.
343    #[must_use]
344    pub fn resolve(&self, col_idx: usize, kind: ColumnKind) -> ResolvedColumnFormat {
345        let o = self.column_overrides.get(col_idx);
346        ResolvedColumnFormat {
347            kind,
348            number: o.and_then(|o| o.number).unwrap_or(self.default_number),
349            date: o
350                .and_then(|o| o.date.clone())
351                .unwrap_or_else(|| self.default_date.clone()),
352            boolean: o
353                .and_then(|o| o.boolean.clone())
354                .unwrap_or_else(|| self.default_boolean.clone()),
355            string: o
356                .and_then(|o| o.string.clone())
357                .unwrap_or_else(|| self.default_string.clone()),
358            null: o
359                .and_then(|o| o.null.clone())
360                .unwrap_or_else(|| self.default_null.clone()),
361            replacements: o
362                .and_then(|o| o.replacements.clone())
363                .unwrap_or_else(|| self.default_replacements.clone()),
364            replacement_timing: o
365                .and_then(|o| o.replacement_timing)
366                .unwrap_or(self.replacement_timing),
367        }
368    }
369
370    /// Resolve formats for every column. Used during state initialization and
371    /// when the config changes.
372    #[must_use]
373    pub fn resolve_all(&self, columns: &[crate::data::Column]) -> Vec<ResolvedColumnFormat> {
374        columns
375            .iter()
376            .enumerate()
377            .map(|(i, c)| self.resolve(i, c.kind))
378            .collect()
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use gpui::Keystroke;
386
387    fn ks(key: &str, platform: bool, shift: bool, alt: bool, control: bool) -> Keystroke {
388        Keystroke {
389            key: key.into(),
390            modifiers: gpui::Modifiers {
391                platform,
392                shift,
393                alt,
394                control,
395                function: false,
396            },
397            ..Default::default()
398        }
399    }
400
401    #[test]
402    fn resolve_uses_defaults_without_override() {
403        let cfg = GridConfig::default();
404        let cols = vec![
405            crate::data::Column::new("a", ColumnKind::Text, 80.0),
406            crate::data::Column::new("b", ColumnKind::Integer, 80.0),
407        ];
408        let resolved = cfg.resolve_all(&cols);
409        assert_eq!(resolved.len(), 2);
410        assert_eq!(resolved[0].kind, ColumnKind::Text);
411        assert_eq!(resolved[1].kind, ColumnKind::Integer);
412        assert_eq!(resolved[0].number.alignment, TextAlignment::Right);
413        assert_eq!(resolved[0].string.alignment, TextAlignment::Left);
414    }
415
416    #[test]
417    fn resolve_uses_per_column_override() {
418        let cfg = GridConfig {
419            column_overrides: vec![
420                ColumnOverride {
421                    number: Some(NumberFormat {
422                        decimals: 4,
423                        ..NumberFormat::default()
424                    }),
425                    ..Default::default()
426                },
427                ColumnOverride::default(),
428            ],
429            ..GridConfig::default()
430        };
431        let cols = vec![
432            crate::data::Column::new("a", ColumnKind::Decimal, 80.0),
433            crate::data::Column::new("b", ColumnKind::Decimal, 80.0),
434        ];
435        let resolved = cfg.resolve_all(&cols);
436        assert_eq!(resolved[0].number.decimals, 4);
437        assert_eq!(resolved[1].number.decimals, 2);
438    }
439
440    #[test]
441    fn key_binding_matches_exact_modifier_set() {
442        let binding = KeyBinding {
443            key: "c".into(),
444            platform: true,
445            shift: false,
446            alt: false,
447            control: false,
448        };
449        assert!(binding.matches(&ks("c", true, false, false, false)));
450        // Adding extra modifiers (Alt) should NOT match a binding that didn't request it.
451        assert!(!binding.matches(&ks("c", true, false, true, false)));
452        assert!(!binding.matches(&ks("c", true, false, false, true)));
453        // Wrong key never matches.
454        assert!(!binding.matches(&ks("x", true, false, false, false)));
455    }
456
457    #[test]
458    fn key_binding_with_no_required_modifier_only_matches_bare_key() {
459        let binding = KeyBinding {
460            key: "pagedown".into(),
461            platform: false,
462            shift: false,
463            alt: false,
464            control: false,
465        };
466        assert!(binding.matches(&ks("pagedown", false, false, false, false)));
467        assert!(!binding.matches(&ks("pagedown", true, false, false, false)));
468    }
469
470    #[test]
471    fn key_binding_with_alt_true_accepts_alt_modifier() {
472        let binding = KeyBinding {
473            key: "c".into(),
474            platform: true,
475            shift: false,
476            alt: true,
477            control: false,
478        };
479        assert!(binding.matches(&ks("c", true, false, true, false)));
480        assert!(!binding.matches(&ks("c", true, false, false, false)));
481    }
482}