1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Kind {
14 Bool,
15 Count,
17 Text,
18 Path,
20 List,
22 Choice(&'static [&'static str]),
24 Size,
26 Duration,
28 Color,
30 Toml(&'static str),
32 Tables,
34}
35
36impl Kind {
37 pub fn describe(&self) -> String {
39 match self {
40 Kind::Bool => "bool".into(),
41 Kind::Count => "integer".into(),
42 Kind::Text => "string".into(),
43 Kind::Path => "path".into(),
44 Kind::List => "list".into(),
45 Kind::Choice(words) => words.join(" \\| "),
46 Kind::Size => "size".into(),
47 Kind::Duration => "duration".into(),
48 Kind::Color => "color".into(),
49 Kind::Toml(shape) => (*shape).into(),
50 Kind::Tables => "tables".into(),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum DefaultValue {
58 Value(&'static str),
59 Unset(&'static str),
61 Color {
63 dark: &'static str,
64 light: &'static str,
65 },
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct Setting {
71 pub key: &'static str,
73 pub kind: Kind,
74 pub default: DefaultValue,
75 pub doc: &'static str,
77 pub flag: Option<&'static str>,
79 pub kwarg: Option<&'static str>,
81 pub spec: Option<&'static str>,
83}
84
85const fn s(key: &'static str, kind: Kind, default: DefaultValue, doc: &'static str) -> Setting {
86 Setting {
87 key,
88 kind,
89 default,
90 doc,
91 flag: None,
92 kwarg: None,
93 spec: None,
94 }
95}
96
97impl Setting {
98 const fn flag(mut self, flag: &'static str) -> Self {
99 self.flag = Some(flag);
100 self
101 }
102
103 const fn kwarg(mut self, kwarg: &'static str) -> Self {
104 self.kwarg = Some(kwarg);
105 self
106 }
107
108 const fn spec(mut self, key: &'static str) -> Self {
109 self.spec = Some(key);
110 self
111 }
112
113 pub fn section(&self) -> &'static str {
115 self.key.rsplit_once('.').map_or("", |(s, _)| s)
116 }
117
118 pub fn name(&self) -> &'static str {
120 self.key.rsplit_once('.').map_or(self.key, |(_, n)| n)
121 }
122
123 pub fn matches(&self, key: &str) -> bool {
125 match self.key.strip_suffix(".*") {
126 Some(prefix) => key
127 .strip_prefix(prefix)
128 .and_then(|rest| rest.strip_prefix('.'))
129 .is_some_and(|name| !name.is_empty() && !name.contains('.')),
130 None => self.key == key,
131 }
132 }
133}
134
135use DefaultValue::{Unset, Value};
136use Kind::*;
137
138const fn color(
139 key: &'static str,
140 dark: &'static str,
141 light: &'static str,
142 doc: &'static str,
143) -> Setting {
144 s(key, Color, DefaultValue::Color { dark, light }, doc)
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct Section {
150 pub name: &'static str,
151 pub title: &'static str,
152 pub intro: &'static str,
154}
155
156pub const SECTIONS: &[Section] = &[
158 Section {
159 name: "",
160 title: "Top level",
161 intro: "",
162 },
163 Section {
164 name: "read",
165 title: "Read",
166 intro: "How files are read. A file's own layout (delimiter, header, rows to skip) is a flag for that file, not a setting.",
167 },
168 Section {
169 name: "csv",
170 title: "CSV",
171 intro: "CSV, TSV and PSV. A [delimited format spec](../formats/format-specs.md#delimited-text) takes these keys too.",
172 },
173 Section {
174 name: "display",
175 title: "Display",
176 intro: "",
177 },
178 Section {
179 name: "performance",
180 title: "Performance",
181 intro: "The rows the table buffers between reads, and the engine.",
182 },
183 Section {
184 name: "analysis",
185 title: "Analysis",
186 intro: "Analysis, Data Quality and charts.",
187 },
188 Section {
189 name: "chart",
190 title: "Chart",
191 intro: "Charts exported to a file (`e` in the chart view).",
192 },
193 Section {
194 name: "home",
195 title: "Home",
196 intro: "The home screen.",
197 },
198 Section {
199 name: "home.search",
200 title: "Home search",
201 intro: "Searching below the working directory as you type on the home screen.",
202 },
203 Section {
204 name: "cloud",
205 title: "Cloud",
206 intro: "See [Cloud sources](cloud-sources.md) for `[[cloud.connections]]`.",
207 },
208 Section {
209 name: "http",
210 title: "HTTP",
211 intro: "Every request datui makes: HTTP(S) files, cloud stores and their sign-ins.",
212 },
213 Section {
214 name: "query",
215 title: "Query",
216 intro: "",
217 },
218 Section {
219 name: "views",
220 title: "Views",
221 intro: "",
222 },
223 Section {
224 name: "clipboard",
225 title: "Clipboard",
226 intro: "How the copy dialog (`y`) reaches the system clipboard.",
227 },
228 Section {
229 name: "formats",
230 title: "Formats",
231 intro: "Where [format specs](../formats/format-specs.md) and dictionaries are found.",
232 },
233 Section {
234 name: "limits",
235 title: "Limits",
236 intro: "How much of a file some readers take in. Past a limit, the rest is left out or the file is refused, and the note or error names the key that raises the limit.",
237 },
238 Section {
239 name: "log",
240 title: "Log",
241 intro: "",
242 },
243 Section {
244 name: "theme",
245 title: "Theme",
246 intro: "",
247 },
248 Section {
249 name: "theme.colors",
250 title: "Colors",
251 intro: "Each slot takes a name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`. They override the theme in use, `theme.dark` or `theme.light`, in either mode; a whole theme of your own goes in a file in `themes/`.",
252 },
253 Section {
254 name: "glyphs",
255 title: "Glyphs",
256 intro: "",
257 },
258];
259
260pub const SETTINGS: &[Setting] = &[
262 s("import", List, Value("[]"), "Config files merged in before this one, in order; this file's own values win. Paths may be relative to this file, or use ~ and $VAR."),
264 s("catalogs", Toml("list of path \\| { path, id, label }"), Value("[]"), "Catalog files elsewhere, listed on the home screen after catalog.toml and the config directory's catalogs/*.toml, each a section; see Catalogs. Each is a path, or { path, id, label } to give it another id or label. Paths may be relative to this file. Adds up across imports."),
265 s("read.infer_types", Toml("bool \\| list of columns"), Value("true"), "Read string columns as dates, times, durations or numbers where every value parses, after trimming: true for all, false for none, or a list of columns. CSV, and dates in JSON. A column with a leading zero (02134) stays text; a later value that does not parse is null, and the Notes tab counts them.").flag("infer-types").kwarg("infer_types"),
267 s("read.parquet_schema", Choice(&["union", "first"]), Value("\"union\""), "The schema of a partitioned Parquet dataset: union takes every column any file has, from their footers; first lets Polars take one file's schema.").kwarg("parquet_schema"),
268 s("read.decompress_in_memory", Bool, Value("false"), "Decompress a compressed CSV, TSV or PSV into memory instead of to a temp file.").kwarg("decompress_in_memory"),
269 s("read.temp_dir", Path, Unset("\"/tmp\""), "Directory for decompression temp files. Unset, the system's temp directory.").flag("temp-dir").kwarg("temp_dir"),
270 s("read.follow_interval", Duration, Value("\"250ms\""), "With --follow, how often the file is checked for new rows (on Linux, the shortest time between two reads), from 10ms to 1m. Rows appended within one interval arrive in one refresh."),
271 s("read.exact_count_files", Count, Value("50000"), "A dataset of more files than this shows a row count estimated from a sample of its footers, until c in the Info panel counts exactly; 0 always counts exactly."),
272 s("read.memory_warning", Size, Value("\"1GiB\""), "Ask before reading more than this of a file whole into memory (JSON, Avro, ORC, Excel and the other formats read in memory); 0 never asks."),
273 s("read.audio_float", Bool, Value("false"), "Show integer audio samples as float in [-1, 1].").kwarg("audio_float"),
274 s("csv.comment", Text, Unset("\"#\""), "Lines starting with this are comments, before the header and among the data.").flag("comment").kwarg("comment").spec("comment"),
276 s("csv.header_join", Text, Value("\" \""), "Joins a column's names when --header-rows names several lines.").kwarg("header_join").spec("header_join"),
277 s("csv.skip_initial_space", Bool, Value("false"), "Ignore the spaces after a delimiter, so padded numbers are numbers and a cell of spaces is null.").flag("skip-initial-space").kwarg("skip_initial_space").spec("skip_initial_space"),
278 s("csv.null_values", List, Value("[]"), "Values read as null: VAL in every column, COL=VAL in column COL only. --null is repeatable and replaces this list.").flag("null").kwarg("null_values").spec("null_values"),
279 s("csv.infer_rows", Count, Value("1000"), "Rows read to infer column types.").flag("infer-rows").kwarg("infer_rows"),
280 s("csv.ignore_errors", Bool, Value("false"), "Skip rows that do not parse instead of failing.").flag("ignore-errors").kwarg("ignore_errors"),
281 s("display.unicode", Choice(&["auto", "always", "never"]), Value("\"auto\""), "Box-drawing and arrow glyphs, or plain ASCII. auto uses them when the locale is UTF-8, or on Windows when no locale is set."),
283 s("display.row_numbers", Toml("\"auto\" \\| bool"), Value("\"auto\""), "Number rows on the left by their place in the source; a row keeps its number through a sort or filter (# toggles). auto numbers text and logs; true or false turns them on or off for every table.").flag("row-numbers").kwarg("row_numbers"),
284 s("display.row_numbers_start", Count, Value("1"), "The number of the source's first row.").kwarg("row_numbers_start"),
285 s("display.cell_padding", Toml("\"comfortable\" \\| \"compact\" \\| integer"), Value("\"comfortable\""), "Space between columns: comfortable (2 cells), compact (1) or a number of cells."),
286 s("display.column_colors", Bool, Value("true"), "Color cells by column type.").kwarg("column_colors"),
287 s("display.type_row", Bool, Value("true"), "A second header row naming each column's type (D toggles)."),
288 s("display.notes_accent", Bool, Value("true"), "Accent the i key when datui has noticed something about the data."),
289 s("display.mouse", Bool, Value("true"), "Use the mouse: the wheel scrolls and a click selects. false leaves the mouse to the terminal.").flag("mouse"),
290 s("display.scroll_region", Bool, Value("true"), "Scroll by moving lines within the terminal, so a scroll writes only the new lines, not the whole page. false redraws every line that moved, for a terminal that mishandles the move."),
291 s("display.sidebar_width", Count, Unset("70"), "Width of every sidebar, in cells. Unset, each sidebar uses its own width."),
292 s("display.right_align_numbers", Bool, Value("true"), "Right-align numeric columns and their headers.").kwarg("right_align_numbers"),
293 s("display.number_format", Toml("preset \\| table"), Value("\"none\""), "Digit grouping: none, thousands, european, si, swiss, indian, underscore or system, or a [display.number_format] table (, toggles).").flag("number-format").kwarg("number_format"),
294 s("performance.pages_ahead", Count, Value("3"), "Pages of rows buffered ahead of the screen.").kwarg("pages_ahead"),
296 s("performance.pages_behind", Count, Value("3"), "Pages of rows buffered behind the screen.").kwarg("pages_behind"),
297 s("performance.max_buffered_rows", Count, Value("100000"), "Most rows the table buffers between reads; 0 for no limit.").kwarg("max_buffered_rows"),
298 s("performance.max_buffered", Size, Value("\"512MiB\""), "Most memory the buffered rows may take, estimated from the schema; 0 for no limit. Rounded up to whole MiB.").kwarg("max_buffered"),
299 s("performance.streaming", Bool, Value("true"), "Use the Polars streaming engine where it applies.").kwarg("streaming"),
300 s("performance.threads", Count, Value("0"), "Most threads Polars computes with; 0 for every core. It limits speed, not memory. POLARS_MAX_THREADS, when set, wins. Applies to the datui command only: the Python module runs its own Polars, sized by POLARS_MAX_THREADS when it first computes."),
301 s("analysis.sample_rows", Count, Value("100000"), "Rows an analysis samples from a larger table, spread across the whole table; 0 reads every row.").flag("sample-rows").kwarg("sample_rows"),
303 s("analysis.chart_rows", Count, Value("10000"), "Rows a chart reads; a larger table is sampled across its whole length."),
304 s("analysis.chart_grid", Bool, Value("false"), "Start charts with a grid at the major ticks (g toggles)."),
305 s("analysis.quality_local_copy", Size, Value("\"2GiB\""), "Largest remote dataset a Data Quality full scan copies into the cache, so it is fetched once and every pass reads the copy; 0 never copies."),
306 s("analysis.sample_memory_limit", Size, Unset("\"8GiB\""), "Most memory a view's sample may take. Unset, the memory available now decides; 0 never warns or stops."),
307 s("chart.export_recipe", Bool, Value("true"), "Embed how an exported chart was made (source path, query, chart, sample) in its PNG, SVG or PDF. The export dialog's Recipe row starts from it."),
308 s("home.desktop_recents", Bool, Value("true"), "Also list directories from the desktop's recently-used files; never the file names."),
310 s("home.wordmark", Bool, Value("true"), "Show the datui wordmark at the top of the home screen; false shows a one-line title bar instead."),
311 s("home.show_unreadable", Bool, Value("false"), "List files datui cannot read, dimmed (Ctrl+A toggles)."),
312 s("home.hide", List, Value("[]"), "Catalogs not shown, by id: mine (catalog.toml), examples, or a listed file's name; one entry as catalog/id, such as examples/nyc-taxis. Adds up across imports."),
313 s("home.preview_max", Size, Value("\"64MiB\""), "Largest local file whose first rows the home screen previews; 0 turns the preview off."),
314 s("home.search.enabled", Bool, Value("true"), "Search below the working directory as you type."),
315 s("home.search.max_depth", Count, Value("8"), "How many directories deep the search goes."),
316 s("home.search.max_results", Count, Value("1000"), "Matches listed; the rest are counted."),
317 s("home.search.time_budget", Duration, Value("\"1500ms\""), "How long the search walks before keeping what it found."),
318 s("home.search.cross_filesystems", Bool, Value("false"), "Descend into other filesystems, network mounts included."),
319 s("home.search.follow_gitignore", Bool, Value("false"), "Skip what .gitignore ignores."),
320 s("home.search.skip", List, Value("[\"node_modules\", \"target\", \"build\", \"dist\", \"vendor\", \"site-packages\", \"__pycache__\", \"venv\", \"env\"]"), "Directory names never searched. Replaces the defaults; skip_extra adds to them."),
321 s("home.search.skip_extra", List, Value("[]"), "Directory names never searched, besides skip."),
322 s("home.search.extensions", List, Value("[]"), "Extensions searched for; empty means those of the formats datui reads."),
323 s("cloud.connections", Tables, Unset("[]"), "Cloud stores to list on the home screen; see Cloud sources."),
325 s("cloud.hide", List, Value("[]"), "Cloud source IDs not shown on the home screen. Adds up across imports."),
326 s("cloud.use_azure_account_keys", Bool, Value("true"), "Read an Azure account with its access keys when a sign-in has no data role, as the Portal does."),
327 s("cloud.env_files", List, Value("[]"), "Files to read cloud variables from, relative to the working directory, such as .env. Adds up across imports."),
328 s("cloud.instance_identity", Bool, Value("false"), "Use the identity of the cloud VM datui runs on (EC2, GCE, Azure)."),
329 s("cloud.discover", Toml("bool \\| \"all\" \\| \"none\" \\| list"), Unset("true"), "Logins found on this machine that become home-screen sources: all (unset), none, or kinds from s3, gcs, azure."),
330 s("cloud.list_on_start", Bool, Value("false"), "List every source's buckets when the home screen opens, not when one is entered."),
331 s("http.user_agent", Text, Value("\"\""), "The User-Agent header on every request. Empty sends datui/VERSION (+https://github.com/derekwisong/datui), which names datui and its version and nothing about you."),
333 s("query.history_limit", Count, Value("1000"), "Queries remembered."),
335 s("query.history", Bool, Value("true"), "Remember queries."),
336 s("query.default_mode", Choice(&["sql", "q"]), Value("\"sql\""), "The language the : command line starts in, until Ctrl+T picks another."),
337 s("views.auto_apply", Bool, Value("false"), "Apply the best-matching view when a file opens."),
339 s("clipboard.backend", Choice(&["auto", "native", "osc52"]), Value("\"auto\""), "auto uses the display server where one answers, and osc52 elsewhere (SSH). osc52 is an escape sequence that asks the terminal to copy."),
341 s("clipboard.osc52_limit", Size, Value("\"100KiB\""), "Longest osc52 copy to attempt, as base64. Terminals cap what they accept."),
342 s("formats.path", List, Value("[]"), "Directories of format specs and dictionaries, searched after ~/.config/datui/formats and $DATUI_FORMATS_PATH. Adds up across imports."),
344 s("limits.indexed_records", Count, Value("67108864"), "Most records one pass indexes: a flight log's messages, a candump's frames, a text file's lines, a format spec's tagged records."),
346 s("limits.elf_symbols", Count, Value("10000000"), "Most symbols read from an ELF file."),
347 s("limits.midi_bytes", Size, Value("\"64MiB\""), "Largest MIDI file read; a larger one is refused."),
348 s("limits.midi_events", Count, Value("10000000"), "Most events read from MIDI files, all files of one open together."),
349 s("limits.journal_bytes", Size, Value("\"1GiB\""), "Most journal JSON read into memory, all files of one open together; the records past it are left out, and the Notes tab says how much."),
350 s("limits.detail_rows", Count, Value("10000"), "Most rows of a list on an Info panel tab (symbols, sections, metadata); one more row says how many were left out."),
351 s("limits.sdf_fields", Count, Value("4096"), "Most fields (data items by name) read from an SDF file, each a column."),
352 s("limits.vcd_signals", Count, Value("1048576"), "Most signals read from a VCD file, each a column."),
353 s("limits.fix_tags", Count, Value("4096"), "Most tags read from a FIX file, each a column."),
354 s("limits.fix_fields", Count, Value("4096"), "Most fields read from one FIX message."),
355 s("limits.gpx_fields", Count, Value("256"), "Most extension fields read from a GPX file, each a column."),
356 s("limits.npy_header_bytes", Size, Value("\"4MiB\""), "Largest NumPy header read; a file with a larger one is refused."),
357 s("log.file", Path, Unset("\"~/datui.log\""), "Where the log is written. Unset, datui.log in the cache directory.").flag("log-file"),
358 s("log.level", Choice(&["error", "warn", "info", "debug", "trace", "off"]), Unset("\"warn\""), "How much the log records (default warn). DATUI_LOG overrides the config file; -c and --log-level override DATUI_LOG.").flag("log-level"),
359 s("theme.mode", Choice(&["auto", "dark", "light"]), Unset("\"auto\""), "Which mode's theme to use: theme.dark or theme.light. auto follows the terminal's answer about its background, else its last answer, then COLORFGBG, then dark; it asks again when the terminal regains focus."),
361 s("theme.dark", Text, Value("\"night-market\""), "The theme used when the terminal is dark: night-market, day-market, or a file's name in the config directory's themes/. A name that cannot be used falls back to night-market, with a warning when dark is in use."),
362 s("theme.light", Text, Value("\"day-market\""), "The theme used when the terminal is light: night-market, day-market, or a file's name in the config directory's themes/. A name that cannot be used falls back to day-market, with a warning when light is in use."),
363 color("theme.colors.chip_key", "#7dcfff", "#2e7de9", "Keys named in the footer, dialogs, the breadcrumb and the correlation matrix."),
364 color("theme.colors.chip_label", "#a9b1d6", "#3760bf", "Labels beside keys in the footer, and the footer's status."),
365 color("theme.colors.throbber", "#7dcfff", "#2e7de9", "The busy spinner."),
366 color("theme.colors.success", "#9ece6a", "#587539", "Success."),
367 color("theme.colors.error", "#f7768e", "#f52a65", "Errors."),
368 color("theme.colors.warning", "#e0af68", "#8c6c3e", "Warnings."),
369 color("theme.colors.dimmed", "#565f89", "#848cb5", "Dimmed text, nulls and axes."),
370 color("theme.colors.background", "default", "default", "Main background."),
371 color("theme.colors.surface", "default", "default", "Dialog background."),
372 color("theme.colors.controls_bg", "#262a3f", "#d0d5e3", "Count chips and dialogs' key chips."),
373 color("theme.colors.text_primary", "default", "default", "Text."),
374 color("theme.colors.text_secondary", "#737aa2", "#6172b0", "Secondary text."),
375 color("theme.colors.text_inverse", "#1a1b26", "#e1e2e7", "Text on a key chip."),
376 color("theme.colors.table_header", "#c0caf5", "#3760bf", "Header text."),
377 color("theme.colors.table_header_bg", "#2b3047", "#c4c8da", "Header fill."),
378 color("theme.colors.table_row_numbers", "#565f89", "#848cb5", "The row-number column."),
379 color("theme.colors.table_column_separator", "#3b4261", "#a8aecb", "The rule after frozen columns and beside section titles."),
380 color("theme.colors.table_selected", "#283457", "#b6bfe2", "Tint under the current row; reversed swaps text and background instead."),
381 color("theme.colors.table_column_cursor", "#292e42", "#cbd3f2", "Tint under the column cursor's cells."),
382 color("theme.colors.table_cell_cursor", "#3b4261", "#a0aef0", "The column cursor's header and the current cell."),
383 color("theme.colors.sidebar_border", "#565f89", "#6172b0", "Sidebar and dialog borders."),
384 color("theme.colors.modal_border_active", "#7dcfff", "#2e7de9", "The focused dialog's border."),
385 color("theme.colors.modal_border_error", "#f7768e", "#f52a65", "An error dialog's border."),
386 color("theme.colors.distribution_normal", "#9ece6a", "#587539", "Analysis: a normal distribution."),
387 color("theme.colors.distribution_skewed", "#e0af68", "#8c6c3e", "Analysis: a skewed distribution."),
388 color("theme.colors.distribution_other", "#c0caf5", "#3760bf", "Analysis: other distributions."),
389 color("theme.colors.outlier_marker", "#f7768e", "#f52a65", "Analysis: outliers."),
390 color("theme.colors.input_cursor", "default", "default", "The text caret; default reverses the text under it."),
391 color("theme.colors.input_cursor_text", "default", "default", "Text under the caret block; default picks black or white by contrast."),
392 color("theme.colors.table_alternate_row", "#1e2030", "#dcdfea", "Every other row; default turns the stripe off."),
393 color("theme.colors.type_str", "#9ece6a", "#587539", "String columns."),
394 color("theme.colors.type_int", "#7aa2f7", "#2e7de9", "Integer columns."),
395 color("theme.colors.type_float", "#2ac3de", "#007197", "Float columns."),
396 color("theme.colors.type_bool", "#e0af68", "#8c6c3e", "Boolean columns."),
397 color("theme.colors.type_temporal", "#bb9af7", "#9854f1", "Date, time and datetime columns."),
398 color("theme.colors.type_binary", "#565f89", "#848cb5", "Binary columns' placeholder."),
399 color("theme.colors.chart_1", "#7dcfff", "#2e7de9", "Chart series 1; also histogram bars, bar charts and Q-Q points."),
400 color("theme.colors.chart_2", "#bb9af7", "#9854f1", "Chart series 2."),
401 color("theme.colors.chart_3", "#9ece6a", "#587539", "Chart series 3."),
402 color("theme.colors.chart_4", "#e0af68", "#8c6c3e", "Chart series 4."),
403 color("theme.colors.chart_5", "#7aa2f7", "#007197", "Chart series 5."),
404 color("theme.colors.chart_6", "#f7768e", "#f52a65", "Chart series 6."),
405 color("theme.colors.chart_7", "#ff9e64", "#b15c00", "Chart series 7."),
406 color("theme.colors.chart_8", "#1abc9c", "#118c74", "Chart series 8."),
407 color("theme.colors.chart_9", "#ff5fd2", "#d1188c", "Chart series 9."),
408 color("theme.colors.chart_10", "#f4ef8a", "#24357a", "Chart series 10."),
409 color("theme.colors.chart_grid", "#3d4785", "#70aabf", "The chart grid, a shade dimmer than dimmed."),
410 color("theme.colors.accent", "#7dcfff", "#2e7de9", "Key chips, focused titles and the selection rail."),
411 color("theme.colors.accent_bright", "#a4daff", "#1a6cd0", "The section the cursor is in."),
412 color("theme.colors.gradient_start", "#7aa2f7", "#2e7de9", "The wordmark's first stop."),
413 color("theme.colors.gradient_end", "#bb9af7", "#9854f1", "The wordmark's last stop."),
414 color("theme.colors.find_match", "#e0af68", "#f0c35a", "Behind the cell a find landed on."),
415 color("theme.colors.hex_null", "#565f89", "#848cb5", "Hex view: the byte 0x00."),
416 color("theme.colors.hex_printable", "#7dcfff", "#007197", "Hex view: printable ASCII."),
417 color("theme.colors.hex_whitespace", "#9ece6a", "#587539", "Hex view: whitespace bytes."),
418 color("theme.colors.hex_control", "#bb9af7", "#9854f1", "Hex view: other control bytes."),
419 color("theme.colors.hex_high", "#e0af68", "#8c6c3e", "Hex view: 0x80 to 0xFE."),
420 color("theme.colors.hex_ff", "#f7768e", "#f52a65", "Hex view: the byte 0xFF."),
421 s("glyphs.*", Toml("string \\| list"), Unset("in_object_store = \"☁\""), "A glyph slot from glyphs.rs, replaced when the Unicode set is active. Keeps the width of the glyph it replaces."),
423];
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub struct OpenOption {
429 pub flag: &'static str,
431 pub kwarg: &'static str,
432 pub kind: Kind,
433 pub spec: Option<&'static str>,
435}
436
437const fn open(flag: &'static str, kwarg: &'static str, kind: Kind) -> OpenOption {
438 OpenOption {
439 flag,
440 kwarg,
441 kind,
442 spec: None,
443 }
444}
445
446const fn open_spec(
447 flag: &'static str,
448 kwarg: &'static str,
449 kind: Kind,
450 spec: &'static str,
451) -> OpenOption {
452 OpenOption {
453 flag,
454 kwarg,
455 kind,
456 spec: Some(spec),
457 }
458}
459
460pub const OPEN: &[OpenOption] = &[
462 open("format", "format", Text),
463 open("table", "table", Text),
464 open("hive", "hive", Bool),
465 open(
466 "compression",
467 "compression",
468 Choice(&["gzip", "zstd", "bzip2", "xz"]),
469 ),
470 open("dict", "dict", List),
471 open("view", "view", Text),
472 open_spec("delimiter", "delimiter", Text, "delimiter"),
473 open("no-header", "no_header", Bool),
474 open_spec("header-rows", "header_rows", List, "header_rows"),
475 open("footer-rows", "footer_rows", Count),
476 open("skip-rows", "skip_rows", Count),
477 open_spec("skip-lines", "skip_lines", Count, "skip_lines"),
478];
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum EnvGroup {
483 Datui,
485 Terminal,
487 Programs,
489 Cloud,
491}
492
493impl EnvGroup {
494 pub fn title(self) -> &'static str {
496 match self {
497 Self::Datui => "datui",
498 Self::Terminal => "Terminal",
499 Self::Programs => "Programs datui starts",
500 Self::Cloud => "Cloud logins",
501 }
502 }
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub struct EnvVar {
508 pub names: &'static [&'static str],
510 pub group: EnvGroup,
511 pub doc: &'static str,
513}
514
515const fn env(names: &'static [&'static str], group: EnvGroup, doc: &'static str) -> EnvVar {
516 EnvVar { names, group, doc }
517}
518
519pub const ENVIRONMENT: &[EnvVar] = &[
521 env(
522 &["DATUI_CONFIG_DIR"],
523 EnvGroup::Datui,
524 "The config directory, in place of the platform's (`~/.config/datui` on Linux). Saved views and format specs live there too",
525 ),
526 env(
527 &["DATUI_CACHE_DIR"],
528 EnvGroup::Datui,
529 "The cache directory, in place of the platform's (`~/.cache/datui` on Linux)",
530 ),
531 env(
532 &["DATUI_FORMATS_PATH"],
533 EnvGroup::Datui,
534 "Directories of format specs and dictionaries, separated as `PATH` is, searched before `[formats] path`",
535 ),
536 env(
537 &["DATUI_LOG"],
538 EnvGroup::Datui,
539 "The log level: `error`, `warn`, `info`, `debug`, `trace` or `off`. Overrides `log.level` in a config file; `-c` and `--log-level` override it",
540 ),
541 env(
542 &["DATUI_DEBUG"],
543 EnvGroup::Datui,
544 "`1` shows the debug overlay",
545 ),
546 env(
547 &["DATUI_GCP_PROJECT"],
548 EnvGroup::Datui,
549 "The Google Cloud project to list when projects cannot be searched. Like `GOOGLE_CLOUD_PROJECT`, but read first",
550 ),
551 env(
552 &["DATUI_TRACE_FIRST_ROWS"],
553 EnvGroup::Datui,
554 "A file to write the time (in Unix nanoseconds) to once the first rows are drawn. For benchmarks",
555 ),
556 env(
557 &["NO_COLOR"],
558 EnvGroup::Terminal,
559 "Set to anything to turn off colors: the terminal's own are used for everything",
560 ),
561 env(
562 &["COLORTERM", "TERM", "FORCE_COLOR"],
563 EnvGroup::Terminal,
564 "How many colors the terminal draws: 24-bit, 256 or 16. Theme colors are reduced to fit",
565 ),
566 env(
567 &["COLORFGBG"],
568 EnvGroup::Terminal,
569 "With `theme.mode = \"auto\"`, says whether the background is light or dark, for a terminal that does not answer when asked",
570 ),
571 env(
572 &["TERM_PROGRAM"],
573 EnvGroup::Terminal,
574 "With `theme.mode = \"auto\"`, identifies the terminal, so its last answer about its background picks the first frame's theme; `TERM` is used when it is unset",
575 ),
576 env(
577 &["LC_ALL", "LC_CTYPE", "LANG"],
578 EnvGroup::Terminal,
579 "With `display.unicode = \"auto\"`, the first one set says whether the terminal takes UTF-8; if it does not, glyphs are ASCII. With none set, Windows gets Unicode and other systems ASCII",
580 ),
581 env(
582 &["VISUAL", "EDITOR", "PAGER"],
583 EnvGroup::Programs,
584 "The inspector's `o` opens text in the first one set, else `less` (on Windows, the system's opener)",
585 ),
586 env(
587 &["AWS_PROFILE"],
588 EnvGroup::Cloud,
589 "The AWS profile for `s3://`, else `default`",
590 ),
591 env(
592 &[
593 "AWS_ACCESS_KEY_ID",
594 "AWS_SECRET_ACCESS_KEY",
595 "AWS_SESSION_TOKEN",
596 ],
597 EnvGroup::Cloud,
598 "AWS keys, and the token of temporary ones",
599 ),
600 env(
601 &["AWS_REGION", "AWS_DEFAULT_REGION"],
602 EnvGroup::Cloud,
603 "The AWS region",
604 ),
605 env(
606 &["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"],
607 EnvGroup::Cloud,
608 "An S3-compatible endpoint (MinIO, R2, Ceph); the first one set",
609 ),
610 env(
611 &["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"],
612 EnvGroup::Cloud,
613 "The AWS config and credentials files, in place of `~/.aws/config` and `~/.aws/credentials`",
614 ),
615 env(
616 &[
617 "GOOGLE_APPLICATION_CREDENTIALS",
618 "GOOGLE_SERVICE_ACCOUNT",
619 "GOOGLE_SERVICE_ACCOUNT_PATH",
620 "GOOGLE_SERVICE_ACCOUNT_KEY",
621 ],
622 EnvGroup::Cloud,
623 "A Google Cloud service account or credentials file for `gs://`",
624 ),
625 env(
626 &[
627 "GOOGLE_CLOUD_PROJECT",
628 "GCLOUD_PROJECT",
629 "CLOUDSDK_CORE_PROJECT",
630 "GCP_PROJECT",
631 ],
632 EnvGroup::Cloud,
633 "The Google Cloud project to list buckets in, after `DATUI_GCP_PROJECT`; the first one set",
634 ),
635 env(
636 &["CLOUDSDK_CONFIG"],
637 EnvGroup::Cloud,
638 "The `gcloud` configuration directory, in place of `~/.config/gcloud`",
639 ),
640 env(
641 &["AZURE_STORAGE_CONNECTION_STRING"],
642 EnvGroup::Cloud,
643 "An Azure storage connection string, with `AccountKey` or `SharedAccessSignature`",
644 ),
645 env(
646 &[
647 "AZURE_STORAGE_ACCOUNT_NAME",
648 "AZURE_STORAGE_ACCOUNT_KEY",
649 "AZURE_STORAGE_SAS_TOKEN",
650 ],
651 EnvGroup::Cloud,
652 "An Azure storage account and its key or SAS token",
653 ),
654 env(
655 &[
656 "AZURE_TENANT_ID",
657 "AZURE_CLIENT_ID",
658 "AZURE_CLIENT_SECRET",
659 "AZURE_FEDERATED_TOKEN_FILE",
660 ],
661 EnvGroup::Cloud,
662 "An Azure service principal, or AKS workload identity",
663 ),
664 env(
665 &["AZURE_CONFIG_DIR"],
666 EnvGroup::Cloud,
667 "The Azure CLI's directory, in place of `~/.azure`",
668 ),
669];
670
671pub fn render_environment_markdown() -> String {
673 let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
674 let mut out = String::from(
675 "# Environment variables\n\n\
676 <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs`. Do not edit. -->\n\n\
677 The variables datui reads.\n",
678 );
679 for group in [
680 EnvGroup::Datui,
681 EnvGroup::Terminal,
682 EnvGroup::Programs,
683 EnvGroup::Cloud,
684 ] {
685 out.push_str(&format!("\n## {}\n\n", group.title()));
686 if group == EnvGroup::Cloud {
687 out.push_str(
688 "Read as each provider's own tools read them; a variable set but empty counts as unset. [Connect to cloud storage](../user-guide/remote-data.md) says which login wins, and `[cloud] env_files` can read them from `.env` files.\n\n",
689 );
690 }
691 out.push_str("| Variable | What it does |\n|---|---|\n");
692 for var in ENVIRONMENT.iter().filter(|v| v.group == group) {
693 let names: Vec<String> = var.names.iter().map(|n| format!("`{n}`")).collect();
694 out.push_str(&format!("| {} | {} |\n", names.join(", "), cell(var.doc)));
695 }
696 }
697 out
698}
699
700pub fn find(key: &str) -> Option<&'static Setting> {
702 SETTINGS.iter().find(|s| s.matches(key))
703}
704
705pub fn flag_help(flag: &str) -> String {
708 let setting = by_flag(flag).unwrap_or_else(|| panic!("--{flag} sets no registered key"));
709 format!("{} [config: {}]", setting.doc, setting.key)
710}
711
712pub fn by_kwarg(kwarg: &str) -> Option<&'static Setting> {
714 SETTINGS.iter().find(|s| s.kwarg == Some(kwarg))
715}
716
717pub fn by_flag(flag: &str) -> Option<&'static Setting> {
719 SETTINGS.iter().find(|s| s.flag == Some(flag))
720}
721
722pub fn in_section(section: &str) -> impl Iterator<Item = &'static Setting> + '_ {
724 SETTINGS.iter().filter(move |s| s.section() == section)
725}
726
727pub const TYPES: &[(&str, &str)] = &[
730 (
731 "size",
732 "A number and a unit: `512MiB`, `2GiB`, `100KiB` (`MB`, `GB` are powers of 1000). `0` needs none",
733 ),
734 ("duration", "A number and a unit: `250ms`, `1.5s`, `2m`"),
735 (
736 "list",
737 "In a file, a TOML array; with `-c`, `a,b` or the array",
738 ),
739 (
740 "color",
741 "A name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`",
742 ),
743];
744
745pub fn render_settings_markdown() -> String {
748 let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
749 let mut out = String::from(
750 "# Settings\n\n\
751 <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs settings`. Do not edit. -->\n\n\
752 Set these in `config.toml` (`datui config init` writes one with every key\n\
753 commented out), or for one run with `-c KEY=VALUE`:\n\n\
754 ```bash\n\
755 printf 'a,b\\n1,2\\n' | datui -c display.row_numbers=true\n\
756 ```\n\n\
757 A flag beats `-c`, which beats the config files, which beat the defaults.\n\
758 `datui config keys` lists every key with its value in effect and where it was\n\
759 set. See [Configure datui](../user-guide/configuration.md) for where the file\n\
760 lives, imports, the theme and troubleshooting.\n\n\
761 | Type | Written as |\n\
762 |---|---|\n",
763 );
764 for (kind, written) in TYPES {
765 out.push_str(&format!("| {kind} | {written} |\n"));
766 }
767 for section in SECTIONS {
768 let settings: Vec<&Setting> = in_section(section.name).collect();
769 if settings.is_empty() {
770 continue;
771 }
772 out.push_str(&format!("\n## {}\n\n", section.title));
773 if !section.name.is_empty() {
774 out.push_str(&format!("`[{}]`", section.name));
775 if !section.intro.is_empty() {
776 out.push_str(&format!(" {}", section.intro));
777 }
778 out.push_str("\n\n");
779 } else if !section.intro.is_empty() {
780 out.push_str(&format!("{}\n\n", section.intro));
781 }
782 let colors = settings
783 .iter()
784 .all(|s| matches!(s.default, DefaultValue::Color { .. }));
785 if colors {
786 out.push_str("| Key | Dark | Light | Description |\n|---|---|---|---|\n");
787 } else {
788 out.push_str("| Key | Type | Default | Flag | Description |\n|---|---|---|---|---|\n");
789 }
790 for setting in settings {
791 let key = format!("`{}`", setting.key);
792 match setting.default {
793 DefaultValue::Color { dark, light } => out.push_str(&format!(
794 "| {key} | `{dark}` | `{light}` | {} |\n",
795 cell(setting.doc)
796 )),
797 DefaultValue::Value(v) | DefaultValue::Unset(v) => {
798 let default = match setting.default {
799 DefaultValue::Value(_) => format!("`{}`", cell(v)),
800 _ => "unset".to_string(),
801 };
802 let flag = setting.flag.map(|f| format!("`--{f}`")).unwrap_or_default();
803 out.push_str(&format!(
804 "| {key} | {} | {default} | {flag} | {} |\n",
805 setting.kind.describe(),
806 cell(setting.doc)
807 ));
808 }
809 }
810 }
811 }
812 out.push_str(
813 "\nThe environment variables datui reads are in\n\
814 [Environment variables](environment.md).\n",
815 );
816 out
817}
818
819#[derive(Debug, Clone, PartialEq)]
822pub struct Override {
823 pub key: String,
824 pub value: toml::Value,
825}
826
827impl std::str::FromStr for Override {
828 type Err = String;
829
830 fn from_str(text: &str) -> Result<Self, String> {
833 let Some((key, value)) = text.split_once('=') else {
834 return Err(format!(
835 "\"{text}\" is not KEY=VALUE, as in -c display.row_numbers=true"
836 ));
837 };
838 let key = key.trim();
839 let setting = find(key).ok_or_else(|| unknown_key(key))?;
840 let value = parse_value(setting, value).map_err(|e| {
841 format!(
842 "{key}: {e} ({key} takes {})",
843 setting.kind.describe().replace("\\|", "|")
844 )
845 })?;
846 Ok(Self {
847 key: key.to_string(),
848 value,
849 })
850 }
851}
852
853pub fn parse_value(setting: &Setting, text: &str) -> Result<toml::Value, String> {
855 let trimmed = text.trim();
856 Ok(match setting.kind {
857 Bool => toml::Value::Boolean(parse_bool(trimmed)?),
858 Count => {
859 let n: u64 = trimmed
860 .replace('_', "")
861 .parse()
862 .map_err(|_| format!("\"{trimmed}\" is not a whole number"))?;
863 toml::Value::Integer(i64::try_from(n).map_err(|_| format!("{n} is too large"))?)
864 }
865 Text | Path | Color => toml::Value::String(text.to_string()),
866 List => {
867 if trimmed.starts_with('[') {
868 toml_value(trimmed)
869 .filter(toml::Value::is_array)
870 .ok_or_else(|| format!("\"{trimmed}\" is not a list"))?
871 } else {
872 toml::Value::Array(
873 trimmed
874 .split(',')
875 .map(str::trim)
876 .filter(|item| !item.is_empty())
877 .map(|item| toml::Value::String(item.to_string()))
878 .collect(),
879 )
880 }
881 }
882 Choice(words) => {
883 let word = trimmed.to_ascii_lowercase();
884 if !words.contains(&word.as_str()) {
885 return Err(format!("\"{trimmed}\" is not one of {}", words.join(", ")));
886 }
887 toml::Value::String(word)
888 }
889 Size => {
890 crate::units::parse_size(trimmed)?;
891 toml::Value::String(trimmed.to_string())
892 }
893 Duration => {
894 crate::units::parse_duration(trimmed)?;
895 toml::Value::String(trimmed.to_string())
896 }
897 Toml(_) => toml_value(trimmed).unwrap_or_else(|| toml::Value::String(trimmed.to_string())),
899 Tables => return Err("is a list of tables; write it in a config file".into()),
900 })
901}
902
903pub fn parse_bool(text: &str) -> Result<bool, String> {
905 match text.to_ascii_lowercase().as_str() {
906 "true" | "yes" | "on" | "1" => Ok(true),
907 "false" | "no" | "off" | "0" => Ok(false),
908 _ => Err(format!("\"{text}\" is not true or false")),
909 }
910}
911
912fn toml_value(text: &str) -> Option<toml::Value> {
913 format!("v = {text}")
914 .parse::<toml::Table>()
915 .ok()
916 .and_then(|mut t| t.remove("v"))
917}
918
919fn unknown_key(key: &str) -> String {
921 let near = suggestions(key);
922 let mut message = format!("\"{key}\" is not a config key");
923 if !near.is_empty() {
924 message.push_str(&format!("; did you mean {}?", near.join(" or ")));
925 }
926 message.push_str(" `datui config keys` lists them");
927 message
928}
929
930pub fn suggestions(key: &str) -> Vec<&'static str> {
933 let name = key.rsplit_once('.').map_or(key, |(_, n)| n);
934 let mut scored: Vec<(usize, &'static str)> = SETTINGS
935 .iter()
936 .filter(|s| !s.key.ends_with(".*"))
937 .filter_map(|s| {
938 let distance = edit_distance(key, s.key);
939 let close = distance <= (key.len() / 4).max(2);
940 let other = s.name();
941 let alike = other == name
942 || (other.len() >= 4 && name.contains(other))
943 || (name.len() >= 4 && other.contains(name));
944 (close || alike).then_some((distance, s.key))
945 })
946 .collect();
947 scored.sort();
948 scored.into_iter().take(3).map(|(_, k)| k).collect()
949}
950
951fn edit_distance(a: &str, b: &str) -> usize {
953 let b: Vec<char> = b.chars().collect();
954 let mut row: Vec<usize> = (0..=b.len()).collect();
955 for (i, ca) in a.chars().enumerate() {
956 let mut diagonal = row[0];
957 row[0] = i + 1;
958 for (j, cb) in b.iter().enumerate() {
959 let above = row[j + 1];
960 row[j + 1] = (diagonal + usize::from(ca != *cb))
961 .min(above + 1)
962 .min(row[j] + 1);
963 diagonal = above;
964 }
965 }
966 row[b.len()]
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972
973 #[test]
974 fn an_override_reads_its_value_for_the_key() {
975 let o: Override = "display.mouse=yes".parse().unwrap();
976 assert_eq!(o.value, toml::Value::Boolean(true));
977 let o: Override = "display.row_numbers=auto".parse().unwrap();
978 assert_eq!(o.value, toml::Value::String("auto".into()));
979 let o: Override = "display.row_numbers=false".parse().unwrap();
980 assert_eq!(o.value, toml::Value::Boolean(false));
981 let o: Override = "csv.comment=#".parse().unwrap();
982 assert_eq!(o.value, toml::Value::String("#".into()));
983 let o: Override = "cloud.env_files=a.env, b.env".parse().unwrap();
984 assert_eq!(o.value.as_array().map(Vec::len), Some(2));
985 let o: Override = "home.hide=[\"x\"]".parse().unwrap();
986 assert_eq!(o.value.as_array().map(Vec::len), Some(1));
987 let o: Override = "cloud.discover=s3,gcs".parse().unwrap();
988 assert_eq!(o.value, toml::Value::String("s3,gcs".into()));
989 let o: Override = "cloud.discover=false".parse().unwrap();
990 assert_eq!(o.value, toml::Value::Boolean(false));
991 let o: Override = "glyphs.spinner=[\"a\", \"b\"]".parse().unwrap();
992 assert!(o.value.is_array());
993 let o: Override = "csv.null_values=amount=".parse().unwrap();
995 assert_eq!(o.value.as_array().unwrap()[0].as_str(), Some("amount="));
996 }
997
998 #[test]
999 fn sizes_and_durations_take_their_unit() {
1000 let o: Override = "performance.max_buffered=1GiB".parse().unwrap();
1001 assert_eq!(o.value, toml::Value::String("1GiB".into()));
1002 let e = "performance.max_buffered=512"
1003 .parse::<Override>()
1004 .unwrap_err();
1005 assert!(e.contains("needs a unit"), "{e}");
1006 let o: Override = "read.follow_interval=1s".parse().unwrap();
1007 assert_eq!(o.value, toml::Value::String("1s".into()));
1008 let e = "read.follow_interval=fast".parse::<Override>().unwrap_err();
1009 assert!(e.contains("duration"), "{e}");
1010 }
1011
1012 #[test]
1013 fn an_override_refuses_with_the_way_out() {
1014 let e = "file_loading.comment_char=#"
1015 .parse::<Override>()
1016 .unwrap_err();
1017 assert!(e.contains("did you mean csv.comment"), "{e}");
1018 let e = "performance.polars_streaming=false"
1019 .parse::<Override>()
1020 .unwrap_err();
1021 assert!(e.contains("performance.streaming"), "{e}");
1022 let e = "display.row_number=true".parse::<Override>().unwrap_err();
1023 assert!(e.contains("did you mean display.row_numbers"), "{e}");
1024 let e = "row_numbers=true".parse::<Override>().unwrap_err();
1025 assert!(e.contains("display.row_numbers"), "{e}");
1026 let e = "display.row_numbers_start=one"
1027 .parse::<Override>()
1028 .unwrap_err();
1029 assert!(
1030 e.contains("not a whole number") && e.contains("integer"),
1031 "{e}"
1032 );
1033 let e = "display.mouse=maybe".parse::<Override>().unwrap_err();
1034 assert!(e.contains("not true or false"), "{e}");
1035 let e = "display.unicode=sometimes".parse::<Override>().unwrap_err();
1036 assert!(e.contains("auto, always, never"), "{e}");
1037 let e = "cloud.connections=x".parse::<Override>().unwrap_err();
1038 assert!(e.contains("config file"), "{e}");
1039 let e = "display.mouse".parse::<Override>().unwrap_err();
1040 assert!(e.contains("KEY=VALUE"), "{e}");
1041 let e = "nothing.like.this=1".parse::<Override>().unwrap_err();
1042 assert!(e.contains("config keys"), "{e}");
1043 }
1044
1045 #[test]
1046 fn every_key_is_listed_once_in_a_known_section() {
1047 for (i, setting) in SETTINGS.iter().enumerate() {
1048 assert!(
1049 SECTIONS.iter().any(|s| s.name == setting.section()),
1050 "{} has no section",
1051 setting.key
1052 );
1053 assert!(
1054 SETTINGS[..i].iter().all(|other| other.key != setting.key),
1055 "{} is listed twice",
1056 setting.key
1057 );
1058 assert!(!setting.doc.is_empty(), "{} has no doc", setting.key);
1059 }
1060 }
1061
1062 #[test]
1063 fn a_wildcard_key_matches_one_name_below_it() {
1064 let glyph = find("glyphs.spinner").expect("a glyph slot");
1065 assert_eq!(glyph.key, "glyphs.*");
1066 assert!(find("glyphs").is_none());
1067 assert!(find("glyphs.a.b").is_none());
1068 assert_eq!(find("display.mouse").map(|s| s.key), Some("display.mouse"));
1069 }
1070}