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/// Formatting for numeric (integer / decimal) columns.
52///
53/// **Negatives and color-blind safety.** `show_negative_red` is a *decorative*
54/// channel — a red fill that a red/green-color-blind reader cannot rely on.
55/// The accessible channel is always the sign glyph in the formatted text: a
56/// leading `-`, or wrapping `( … )` when `negative_parentheses` is set. That
57/// glyph is emitted independently of `show_negative_red`, so a negative value
58/// is never distinguished by color alone (WCAG 1.4.1). For financial columns,
59/// prefer `negative_parentheses` — parentheses wrap the whole number and read
60/// far faster than a thin minus when scanning a dense column; the default
61/// keeps the leading minus because it is the correct general-purpose sign for
62/// non-monetary integers (IDs, counts, deltas).
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub struct NumberFormat {
65    /// Digits after the decimal point.
66    pub decimals: usize,
67    /// Paint negative values in the theme's `negative_fg`. Decorative only —
68    /// never the sole cue (see the type-level note on color-blind safety).
69    pub show_negative_red: bool,
70    /// Render negatives as `(123)` instead of `-123`. The stronger,
71    /// accounting-standard sign channel for money columns.
72    pub negative_parentheses: bool,
73    /// Group the integer part with thousands separators (`1,234,567`).
74    pub thousands_separator: bool,
75    /// Horizontal alignment of the value within its cell.
76    pub alignment: TextAlignment,
77}
78
79impl Default for NumberFormat {
80    fn default() -> Self {
81        Self {
82            decimals: 2,
83            show_negative_red: true,
84            negative_parentheses: false,
85            thousands_separator: true,
86            alignment: TextAlignment::Right,
87        }
88    }
89}
90
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct RelativeDateFormat {
93    pub units: Vec<RelativeUnit>,
94    pub max_components: usize,
95}
96
97impl Default for RelativeDateFormat {
98    fn default() -> Self {
99        Self {
100            units: vec![RelativeUnit::Year, RelativeUnit::Month, RelativeUnit::Day],
101            max_components: 1,
102        }
103    }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct DateFormat {
108    pub format: String,
109    pub timezone_offset_minutes: i32,
110    pub relative: Option<RelativeDateFormat>,
111    pub alignment: TextAlignment,
112}
113
114impl Default for DateFormat {
115    fn default() -> Self {
116        Self {
117            format: "%Y-%m-%d".into(),
118            timezone_offset_minutes: 0,
119            relative: None,
120            alignment: TextAlignment::Center,
121        }
122    }
123}
124
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub struct BooleanFormat {
127    pub true_text: String,
128    pub false_text: String,
129    pub alignment: TextAlignment,
130}
131
132impl Default for BooleanFormat {
133    fn default() -> Self {
134        Self {
135            true_text: "true".into(),
136            false_text: "false".into(),
137            alignment: TextAlignment::Center,
138        }
139    }
140}
141
142#[derive(Clone, Debug, PartialEq, Eq)]
143pub struct StringFormat {
144    pub case: TextCase,
145    pub max_length: Option<usize>,
146    pub truncation: TruncationBehavior,
147    pub alignment: TextAlignment,
148}
149
150impl Default for StringFormat {
151    fn default() -> Self {
152        Self {
153            case: TextCase::None,
154            max_length: None,
155            truncation: TruncationBehavior::Ellipsis,
156            alignment: TextAlignment::Left,
157        }
158    }
159}
160
161/// How a cell with no value ([`crate::data::CellValue::None`]) is displayed.
162/// The grid-wide default is set via [`GridConfig::default_null`]; individual
163/// columns can override it through [`ColumnOverride::null`]. Columns without
164/// an override use the grid default, and callers that never set
165/// `default_null` get the built-in default: italic `NULL` over a distinctive
166/// background (`GridTheme::null_bg` / `null_fg`).
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct NullFormat {
169    /// Placeholder text shown in the cell.
170    pub text: String,
171    /// Render the placeholder in italics.
172    pub italic: bool,
173    /// Fill the cell with the theme's `null_bg` behind the placeholder.
174    pub background: bool,
175}
176
177impl Default for NullFormat {
178    fn default() -> Self {
179        Self {
180            text: "NULL".into(),
181            italic: true,
182            background: true,
183        }
184    }
185}
186
187#[derive(Clone, Debug, PartialEq, Eq)]
188pub struct ReplacementRule {
189    pub find: String,
190    pub replace: String,
191}
192
193impl ReplacementRule {
194    /// Convenience constructor.
195    #[must_use]
196    pub fn new(find: impl Into<String>, replace: impl Into<String>) -> Self {
197        Self {
198            find: find.into(),
199            replace: replace.into(),
200        }
201    }
202}
203
204#[derive(Clone, Debug, Default, PartialEq, Eq)]
205pub struct ColumnOverride {
206    pub number: Option<NumberFormat>,
207    pub date: Option<DateFormat>,
208    pub boolean: Option<BooleanFormat>,
209    pub string: Option<StringFormat>,
210    pub null: Option<NullFormat>,
211    pub replacements: Option<Vec<ReplacementRule>>,
212    pub replacement_timing: Option<ReplacementTiming>,
213}
214
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct ResolvedColumnFormat {
217    pub kind: ColumnKind,
218    pub number: NumberFormat,
219    pub date: DateFormat,
220    pub boolean: BooleanFormat,
221    pub string: StringFormat,
222    pub null: NullFormat,
223    pub replacements: Vec<ReplacementRule>,
224    pub replacement_timing: ReplacementTiming,
225}
226
227impl ResolvedColumnFormat {
228    #[must_use]
229    pub fn alignment(&self) -> TextAlignment {
230        match self.kind {
231            ColumnKind::Integer | ColumnKind::Decimal => self.number.alignment,
232            ColumnKind::Date => self.date.alignment,
233            ColumnKind::Boolean => self.boolean.alignment,
234            ColumnKind::Text => self.string.alignment,
235            ColumnKind::None => TextAlignment::Left,
236        }
237    }
238}
239
240#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct KeyBinding {
242    pub key: String,
243    pub platform: bool,
244    pub shift: bool,
245    pub alt: bool,
246    pub control: bool,
247}
248
249impl KeyBinding {
250    /// `true` iff `ks` matches this binding and carries no extra modifiers we
251    /// did not declare.
252    ///
253    /// For example, `Cmd+C` matches `copy`; `Cmd+Alt+C` does not unless `alt`
254    /// is `true` on the binding. This avoids the previous footgun where
255    /// `Cmd+Alt+C` would still satisfy a binding that only declared `Cmd+C`.
256    pub fn matches(&self, ks: &gpui::Keystroke) -> bool {
257        let required = self.platform || self.shift || self.alt || self.control;
258        let actual =
259            ks.modifiers.platform || ks.modifiers.shift || ks.modifiers.alt || ks.modifiers.control;
260        // If the binding requires nothing at all, only match when the user
261        // pressed exactly that key with no modifiers.
262        if !required {
263            return self.key == ks.key && !actual;
264        }
265        self.key == ks.key
266            && self.platform == ks.modifiers.platform
267            && self.shift == ks.modifiers.shift
268            && self.alt == ks.modifiers.alt
269            && self.control == ks.modifiers.control
270    }
271}
272
273#[derive(Clone, Debug, PartialEq, Eq)]
274pub struct KeyBindings {
275    pub select_all: KeyBinding,
276    pub copy: KeyBinding,
277    pub copy_with_headers: KeyBinding,
278    pub page_up: KeyBinding,
279    pub page_down: KeyBinding,
280    pub context_menu_modifier_control: bool,
281    pub context_menu_modifier_alt: bool,
282}
283
284impl Default for KeyBindings {
285    fn default() -> Self {
286        Self {
287            select_all: KeyBinding {
288                key: "a".into(),
289                platform: true,
290                shift: false,
291                alt: false,
292                control: false,
293            },
294            copy: KeyBinding {
295                key: "c".into(),
296                platform: true,
297                shift: false,
298                alt: false,
299                control: false,
300            },
301            copy_with_headers: KeyBinding {
302                key: "c".into(),
303                platform: true,
304                shift: true,
305                alt: false,
306                control: false,
307            },
308            page_up: KeyBinding {
309                key: "pageup".into(),
310                platform: false,
311                shift: false,
312                alt: false,
313                control: false,
314            },
315            page_down: KeyBinding {
316                key: "pagedown".into(),
317                platform: false,
318                shift: false,
319                alt: false,
320                control: false,
321            },
322            context_menu_modifier_control: true,
323            context_menu_modifier_alt: false,
324        }
325    }
326}
327
328#[derive(Clone, Debug, PartialEq, Eq)]
329pub struct GridConfig {
330    pub key_bindings: KeyBindings,
331    pub default_number: NumberFormat,
332    pub default_date: DateFormat,
333    pub default_boolean: BooleanFormat,
334    pub default_string: StringFormat,
335    /// Grid-wide display for cells with no value; per-column override via
336    /// [`ColumnOverride::null`].
337    pub default_null: NullFormat,
338    pub default_replacements: Vec<ReplacementRule>,
339    pub replacement_timing: ReplacementTiming,
340    pub column_overrides: Vec<ColumnOverride>,
341    /// Hint painted centered in the data area when the grid has zero rows
342    /// (e.g. an empty result set). Host-supplied so it can be localized;
343    /// set to an empty string to paint nothing.
344    pub empty_text: String,
345    /// Whether transient surfaces (context menus, filter panels, popovers,
346    /// dialogs, the busy scrim, the pivot drag ghost) fade in on appear.
347    /// `true` by default — a fast, native entrance. GPUI exposes no OS
348    /// reduce-motion signal, so set this to `false` to honor a system "reduce
349    /// motion" preference: every surface then appears instantly. The data
350    /// surface (cells, selection, sort) is always instant regardless.
351    pub animations: bool,
352}
353
354impl Default for GridConfig {
355    fn default() -> Self {
356        Self {
357            key_bindings: KeyBindings::default(),
358            default_number: NumberFormat::default(),
359            default_date: DateFormat::default(),
360            default_boolean: BooleanFormat::default(),
361            default_string: StringFormat::default(),
362            default_null: NullFormat::default(),
363            default_replacements: vec![],
364            replacement_timing: ReplacementTiming::AfterFormat,
365            column_overrides: vec![],
366            empty_text: "No rows".into(),
367            animations: true,
368        }
369    }
370}
371
372impl GridConfig {
373    /// Resolve the format for a single column. Returns a freshly-merged
374    /// [`ResolvedColumnFormat`] every call; the grid state caches the result.
375    #[must_use]
376    pub fn resolve(&self, col_idx: usize, kind: ColumnKind) -> ResolvedColumnFormat {
377        let o = self.column_overrides.get(col_idx);
378        ResolvedColumnFormat {
379            kind,
380            number: o.and_then(|o| o.number).unwrap_or(self.default_number),
381            date: o
382                .and_then(|o| o.date.clone())
383                .unwrap_or_else(|| self.default_date.clone()),
384            boolean: o
385                .and_then(|o| o.boolean.clone())
386                .unwrap_or_else(|| self.default_boolean.clone()),
387            string: o
388                .and_then(|o| o.string.clone())
389                .unwrap_or_else(|| self.default_string.clone()),
390            null: o
391                .and_then(|o| o.null.clone())
392                .unwrap_or_else(|| self.default_null.clone()),
393            replacements: o
394                .and_then(|o| o.replacements.clone())
395                .unwrap_or_else(|| self.default_replacements.clone()),
396            replacement_timing: o
397                .and_then(|o| o.replacement_timing)
398                .unwrap_or(self.replacement_timing),
399        }
400    }
401
402    /// Resolve formats for every column. Used during state initialization and
403    /// when the config changes.
404    #[must_use]
405    pub fn resolve_all(&self, columns: &[crate::data::Column]) -> Vec<ResolvedColumnFormat> {
406        columns
407            .iter()
408            .enumerate()
409            .map(|(i, c)| self.resolve(i, c.kind))
410            .collect()
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use gpui::Keystroke;
418
419    fn ks(key: &str, platform: bool, shift: bool, alt: bool, control: bool) -> Keystroke {
420        Keystroke {
421            key: key.into(),
422            modifiers: gpui::Modifiers {
423                platform,
424                shift,
425                alt,
426                control,
427                function: false,
428            },
429            ..Default::default()
430        }
431    }
432
433    #[test]
434    fn animations_default_on() {
435        // The datatable ships motion enabled by default; hosts opt out for a
436        // reduce-motion preference. Locking the default guards against a silent
437        // flip that would ship the crate mute.
438        assert!(GridConfig::default().animations);
439    }
440
441    #[test]
442    fn resolve_uses_defaults_without_override() {
443        let cfg = GridConfig::default();
444        let cols = vec![
445            crate::data::Column::new("a", ColumnKind::Text, 80.0),
446            crate::data::Column::new("b", ColumnKind::Integer, 80.0),
447        ];
448        let resolved = cfg.resolve_all(&cols);
449        assert_eq!(resolved.len(), 2);
450        assert_eq!(resolved[0].kind, ColumnKind::Text);
451        assert_eq!(resolved[1].kind, ColumnKind::Integer);
452        assert_eq!(resolved[0].number.alignment, TextAlignment::Right);
453        assert_eq!(resolved[0].string.alignment, TextAlignment::Left);
454    }
455
456    #[test]
457    fn resolve_uses_per_column_override() {
458        let cfg = GridConfig {
459            column_overrides: vec![
460                ColumnOverride {
461                    number: Some(NumberFormat {
462                        decimals: 4,
463                        ..NumberFormat::default()
464                    }),
465                    ..Default::default()
466                },
467                ColumnOverride::default(),
468            ],
469            ..GridConfig::default()
470        };
471        let cols = vec![
472            crate::data::Column::new("a", ColumnKind::Decimal, 80.0),
473            crate::data::Column::new("b", ColumnKind::Decimal, 80.0),
474        ];
475        let resolved = cfg.resolve_all(&cols);
476        assert_eq!(resolved[0].number.decimals, 4);
477        assert_eq!(resolved[1].number.decimals, 2);
478    }
479
480    #[test]
481    fn key_binding_matches_exact_modifier_set() {
482        let binding = KeyBinding {
483            key: "c".into(),
484            platform: true,
485            shift: false,
486            alt: false,
487            control: false,
488        };
489        assert!(binding.matches(&ks("c", true, false, false, false)));
490        // Adding extra modifiers (Alt) should NOT match a binding that didn't request it.
491        assert!(!binding.matches(&ks("c", true, false, true, false)));
492        assert!(!binding.matches(&ks("c", true, false, false, true)));
493        // Wrong key never matches.
494        assert!(!binding.matches(&ks("x", true, false, false, false)));
495    }
496
497    #[test]
498    fn key_binding_with_no_required_modifier_only_matches_bare_key() {
499        let binding = KeyBinding {
500            key: "pagedown".into(),
501            platform: false,
502            shift: false,
503            alt: false,
504            control: false,
505        };
506        assert!(binding.matches(&ks("pagedown", false, false, false, false)));
507        assert!(!binding.matches(&ks("pagedown", true, false, false, false)));
508    }
509
510    #[test]
511    fn key_binding_with_alt_true_accepts_alt_modifier() {
512        let binding = KeyBinding {
513            key: "c".into(),
514            platform: true,
515            shift: false,
516            alt: true,
517            control: false,
518        };
519        assert!(binding.matches(&ks("c", true, false, true, false)));
520        assert!(!binding.matches(&ks("c", true, false, false, false)));
521    }
522}