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