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}
346
347impl Default for GridConfig {
348    fn default() -> Self {
349        Self {
350            key_bindings: KeyBindings::default(),
351            default_number: NumberFormat::default(),
352            default_date: DateFormat::default(),
353            default_boolean: BooleanFormat::default(),
354            default_string: StringFormat::default(),
355            default_null: NullFormat::default(),
356            default_replacements: vec![],
357            replacement_timing: ReplacementTiming::AfterFormat,
358            column_overrides: vec![],
359            empty_text: "No rows".into(),
360        }
361    }
362}
363
364impl GridConfig {
365    /// Resolve the format for a single column. Returns a freshly-merged
366    /// [`ResolvedColumnFormat`] every call; the grid state caches the result.
367    #[must_use]
368    pub fn resolve(&self, col_idx: usize, kind: ColumnKind) -> ResolvedColumnFormat {
369        let o = self.column_overrides.get(col_idx);
370        ResolvedColumnFormat {
371            kind,
372            number: o.and_then(|o| o.number).unwrap_or(self.default_number),
373            date: o
374                .and_then(|o| o.date.clone())
375                .unwrap_or_else(|| self.default_date.clone()),
376            boolean: o
377                .and_then(|o| o.boolean.clone())
378                .unwrap_or_else(|| self.default_boolean.clone()),
379            string: o
380                .and_then(|o| o.string.clone())
381                .unwrap_or_else(|| self.default_string.clone()),
382            null: o
383                .and_then(|o| o.null.clone())
384                .unwrap_or_else(|| self.default_null.clone()),
385            replacements: o
386                .and_then(|o| o.replacements.clone())
387                .unwrap_or_else(|| self.default_replacements.clone()),
388            replacement_timing: o
389                .and_then(|o| o.replacement_timing)
390                .unwrap_or(self.replacement_timing),
391        }
392    }
393
394    /// Resolve formats for every column. Used during state initialization and
395    /// when the config changes.
396    #[must_use]
397    pub fn resolve_all(&self, columns: &[crate::data::Column]) -> Vec<ResolvedColumnFormat> {
398        columns
399            .iter()
400            .enumerate()
401            .map(|(i, c)| self.resolve(i, c.kind))
402            .collect()
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use gpui::Keystroke;
410
411    fn ks(key: &str, platform: bool, shift: bool, alt: bool, control: bool) -> Keystroke {
412        Keystroke {
413            key: key.into(),
414            modifiers: gpui::Modifiers {
415                platform,
416                shift,
417                alt,
418                control,
419                function: false,
420            },
421            ..Default::default()
422        }
423    }
424
425    #[test]
426    fn resolve_uses_defaults_without_override() {
427        let cfg = GridConfig::default();
428        let cols = vec![
429            crate::data::Column::new("a", ColumnKind::Text, 80.0),
430            crate::data::Column::new("b", ColumnKind::Integer, 80.0),
431        ];
432        let resolved = cfg.resolve_all(&cols);
433        assert_eq!(resolved.len(), 2);
434        assert_eq!(resolved[0].kind, ColumnKind::Text);
435        assert_eq!(resolved[1].kind, ColumnKind::Integer);
436        assert_eq!(resolved[0].number.alignment, TextAlignment::Right);
437        assert_eq!(resolved[0].string.alignment, TextAlignment::Left);
438    }
439
440    #[test]
441    fn resolve_uses_per_column_override() {
442        let cfg = GridConfig {
443            column_overrides: vec![
444                ColumnOverride {
445                    number: Some(NumberFormat {
446                        decimals: 4,
447                        ..NumberFormat::default()
448                    }),
449                    ..Default::default()
450                },
451                ColumnOverride::default(),
452            ],
453            ..GridConfig::default()
454        };
455        let cols = vec![
456            crate::data::Column::new("a", ColumnKind::Decimal, 80.0),
457            crate::data::Column::new("b", ColumnKind::Decimal, 80.0),
458        ];
459        let resolved = cfg.resolve_all(&cols);
460        assert_eq!(resolved[0].number.decimals, 4);
461        assert_eq!(resolved[1].number.decimals, 2);
462    }
463
464    #[test]
465    fn key_binding_matches_exact_modifier_set() {
466        let binding = KeyBinding {
467            key: "c".into(),
468            platform: true,
469            shift: false,
470            alt: false,
471            control: false,
472        };
473        assert!(binding.matches(&ks("c", true, false, false, false)));
474        // Adding extra modifiers (Alt) should NOT match a binding that didn't request it.
475        assert!(!binding.matches(&ks("c", true, false, true, false)));
476        assert!(!binding.matches(&ks("c", true, false, false, true)));
477        // Wrong key never matches.
478        assert!(!binding.matches(&ks("x", true, false, false, false)));
479    }
480
481    #[test]
482    fn key_binding_with_no_required_modifier_only_matches_bare_key() {
483        let binding = KeyBinding {
484            key: "pagedown".into(),
485            platform: false,
486            shift: false,
487            alt: false,
488            control: false,
489        };
490        assert!(binding.matches(&ks("pagedown", false, false, false, false)));
491        assert!(!binding.matches(&ks("pagedown", true, false, false, false)));
492    }
493
494    #[test]
495    fn key_binding_with_alt_true_accepts_alt_modifier() {
496        let binding = KeyBinding {
497            key: "c".into(),
498            platform: true,
499            shift: false,
500            alt: true,
501            control: false,
502        };
503        assert!(binding.matches(&ks("c", true, false, true, false)));
504        assert!(!binding.matches(&ks("c", true, false, false, false)));
505    }
506}