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.sql_depth", Count, Value("24"), "Deepest a SQL statement may nest, each parenthesis, call, subquery or part of a dotted name a level; a deeper statement is refused. Each level past 24 about doubles the time a broken statement can take to reject: half a second at 28, seconds past 30. At most 50, the parser's own limit, which 0 means too."),
352 s("limits.sdf_fields", Count, Value("4096"), "Most fields (data items by name) read from an SDF file, each a column."),
353 s("limits.vcd_signals", Count, Value("1048576"), "Most signals read from a VCD file, each a column."),
354 s("limits.fix_tags", Count, Value("4096"), "Most tags read from a FIX file, each a column."),
355 s("limits.fix_fields", Count, Value("4096"), "Most fields read from one FIX message."),
356 s("limits.gpx_fields", Count, Value("256"), "Most extension fields read from a GPX file, each a column."),
357 s("limits.npy_header_bytes", Size, Value("\"4MiB\""), "Largest NumPy header read; a file with a larger one is refused."),
358 s("log.file", Path, Unset("\"~/datui.log\""), "Where the log is written. Unset, datui.log in the cache directory.").flag("log-file"),
359 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"),
360 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."),
362 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."),
363 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."),
364 color("theme.colors.chip_key", "#7dcfff", "#2e7de9", "Keys named in the footer, dialogs, the breadcrumb and the correlation matrix."),
365 color("theme.colors.chip_label", "#a9b1d6", "#3760bf", "Labels beside keys in the footer, and the footer's status."),
366 color("theme.colors.throbber", "#7dcfff", "#2e7de9", "The busy spinner."),
367 color("theme.colors.success", "#9ece6a", "#587539", "Success."),
368 color("theme.colors.error", "#f7768e", "#f52a65", "Errors."),
369 color("theme.colors.warning", "#e0af68", "#8c6c3e", "Warnings."),
370 color("theme.colors.dimmed", "#565f89", "#848cb5", "Dimmed text, nulls and axes."),
371 color("theme.colors.background", "default", "default", "Main background."),
372 color("theme.colors.surface", "default", "default", "Dialog background."),
373 color("theme.colors.controls_bg", "#262a3f", "#d0d5e3", "Count chips and dialogs' key chips."),
374 color("theme.colors.text_primary", "default", "default", "Text."),
375 color("theme.colors.text_secondary", "#737aa2", "#6172b0", "Secondary text."),
376 color("theme.colors.text_inverse", "#1a1b26", "#e1e2e7", "Text on a key chip."),
377 color("theme.colors.table_header", "#c0caf5", "#3760bf", "Header text."),
378 color("theme.colors.table_header_bg", "#2b3047", "#c4c8da", "Header fill."),
379 color("theme.colors.table_row_numbers", "#565f89", "#848cb5", "The row-number column."),
380 color("theme.colors.table_column_separator", "#3b4261", "#a8aecb", "The rule after frozen columns and beside section titles."),
381 color("theme.colors.table_selected", "#283457", "#b6bfe2", "Tint under the current row; reversed swaps text and background instead."),
382 color("theme.colors.table_column_cursor", "#292e42", "#cbd3f2", "Tint under the column cursor's cells."),
383 color("theme.colors.table_cell_cursor", "#3b4261", "#a0aef0", "The column cursor's header and the current cell."),
384 color("theme.colors.sidebar_border", "#565f89", "#6172b0", "Sidebar and dialog borders."),
385 color("theme.colors.modal_border_active", "#7dcfff", "#2e7de9", "The focused dialog's border."),
386 color("theme.colors.modal_border_error", "#f7768e", "#f52a65", "An error dialog's border."),
387 color("theme.colors.distribution_normal", "#9ece6a", "#587539", "Analysis: a normal distribution."),
388 color("theme.colors.distribution_skewed", "#e0af68", "#8c6c3e", "Analysis: a skewed distribution."),
389 color("theme.colors.distribution_other", "#c0caf5", "#3760bf", "Analysis: other distributions."),
390 color("theme.colors.outlier_marker", "#f7768e", "#f52a65", "Analysis: outliers."),
391 color("theme.colors.input_cursor", "default", "default", "The text caret; default reverses the text under it."),
392 color("theme.colors.input_cursor_text", "default", "default", "Text under the caret block; default picks black or white by contrast."),
393 color("theme.colors.table_alternate_row", "#1e2030", "#dcdfea", "Every other row; default turns the stripe off."),
394 color("theme.colors.type_str", "#9ece6a", "#587539", "String columns."),
395 color("theme.colors.type_int", "#7aa2f7", "#2e7de9", "Integer columns."),
396 color("theme.colors.type_float", "#2ac3de", "#007197", "Float columns."),
397 color("theme.colors.type_bool", "#e0af68", "#8c6c3e", "Boolean columns."),
398 color("theme.colors.type_temporal", "#bb9af7", "#9854f1", "Date, time and datetime columns."),
399 color("theme.colors.type_binary", "#565f89", "#848cb5", "Binary columns' placeholder."),
400 color("theme.colors.chart_1", "#7dcfff", "#2e7de9", "Chart series 1; also histogram bars, bar charts and Q-Q points."),
401 color("theme.colors.chart_2", "#bb9af7", "#9854f1", "Chart series 2."),
402 color("theme.colors.chart_3", "#9ece6a", "#587539", "Chart series 3."),
403 color("theme.colors.chart_4", "#e0af68", "#8c6c3e", "Chart series 4."),
404 color("theme.colors.chart_5", "#7aa2f7", "#007197", "Chart series 5."),
405 color("theme.colors.chart_6", "#f7768e", "#f52a65", "Chart series 6."),
406 color("theme.colors.chart_7", "#ff9e64", "#b15c00", "Chart series 7."),
407 color("theme.colors.chart_8", "#1abc9c", "#118c74", "Chart series 8."),
408 color("theme.colors.chart_9", "#ff5fd2", "#d1188c", "Chart series 9."),
409 color("theme.colors.chart_10", "#f4ef8a", "#24357a", "Chart series 10."),
410 color("theme.colors.chart_grid", "#3d4785", "#70aabf", "The chart grid, a shade dimmer than dimmed."),
411 color("theme.colors.accent", "#7dcfff", "#2e7de9", "Key chips, focused titles and the selection rail."),
412 color("theme.colors.accent_bright", "#a4daff", "#1a6cd0", "The section the cursor is in."),
413 color("theme.colors.gradient_start", "#7aa2f7", "#2e7de9", "The wordmark's first stop."),
414 color("theme.colors.gradient_end", "#bb9af7", "#9854f1", "The wordmark's last stop."),
415 color("theme.colors.find_match", "#e0af68", "#f0c35a", "Behind the cell a find landed on."),
416 color("theme.colors.hex_null", "#565f89", "#848cb5", "Hex view: the byte 0x00."),
417 color("theme.colors.hex_printable", "#7dcfff", "#007197", "Hex view: printable ASCII."),
418 color("theme.colors.hex_whitespace", "#9ece6a", "#587539", "Hex view: whitespace bytes."),
419 color("theme.colors.hex_control", "#bb9af7", "#9854f1", "Hex view: other control bytes."),
420 color("theme.colors.hex_high", "#e0af68", "#8c6c3e", "Hex view: 0x80 to 0xFE."),
421 color("theme.colors.hex_ff", "#f7768e", "#f52a65", "Hex view: the byte 0xFF."),
422 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."),
424];
425
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429pub struct OpenOption {
430 pub flag: &'static str,
432 pub kwarg: &'static str,
433 pub kind: Kind,
434 pub spec: Option<&'static str>,
436}
437
438const fn open(flag: &'static str, kwarg: &'static str, kind: Kind) -> OpenOption {
439 OpenOption {
440 flag,
441 kwarg,
442 kind,
443 spec: None,
444 }
445}
446
447const fn open_spec(
448 flag: &'static str,
449 kwarg: &'static str,
450 kind: Kind,
451 spec: &'static str,
452) -> OpenOption {
453 OpenOption {
454 flag,
455 kwarg,
456 kind,
457 spec: Some(spec),
458 }
459}
460
461pub const OPEN: &[OpenOption] = &[
463 open("format", "format", Text),
464 open("table", "table", Text),
465 open("hive", "hive", Bool),
466 open(
467 "compression",
468 "compression",
469 Choice(&["gzip", "zstd", "bzip2", "xz"]),
470 ),
471 open("dict", "dict", List),
472 open("view", "view", Text),
473 open_spec("delimiter", "delimiter", Text, "delimiter"),
474 open("no-header", "no_header", Bool),
475 open_spec("header-rows", "header_rows", List, "header_rows"),
476 open("footer-rows", "footer_rows", Count),
477 open("skip-rows", "skip_rows", Count),
478 open_spec("skip-lines", "skip_lines", Count, "skip_lines"),
479];
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483pub enum EnvGroup {
484 Datui,
486 Terminal,
488 Programs,
490 Cloud,
492}
493
494impl EnvGroup {
495 pub fn title(self) -> &'static str {
497 match self {
498 Self::Datui => "datui",
499 Self::Terminal => "Terminal",
500 Self::Programs => "Programs datui starts",
501 Self::Cloud => "Cloud logins",
502 }
503 }
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508pub struct EnvVar {
509 pub names: &'static [&'static str],
511 pub group: EnvGroup,
512 pub doc: &'static str,
514}
515
516const fn env(names: &'static [&'static str], group: EnvGroup, doc: &'static str) -> EnvVar {
517 EnvVar { names, group, doc }
518}
519
520pub const ENVIRONMENT: &[EnvVar] = &[
522 env(
523 &["DATUI_CONFIG_DIR"],
524 EnvGroup::Datui,
525 "The config directory, in place of the platform's (`~/.config/datui` on Linux). Saved views and format specs live there too",
526 ),
527 env(
528 &["DATUI_CACHE_DIR"],
529 EnvGroup::Datui,
530 "The cache directory, in place of the platform's (`~/.cache/datui` on Linux)",
531 ),
532 env(
533 &["DATUI_FORMATS_PATH"],
534 EnvGroup::Datui,
535 "Directories of format specs and dictionaries, separated as `PATH` is, searched before `[formats] path`",
536 ),
537 env(
538 &["DATUI_LOG"],
539 EnvGroup::Datui,
540 "The log level: `error`, `warn`, `info`, `debug`, `trace` or `off`. Overrides `log.level` in a config file; `-c` and `--log-level` override it",
541 ),
542 env(
543 &["DATUI_DEBUG"],
544 EnvGroup::Datui,
545 "`1` shows the debug overlay",
546 ),
547 env(
548 &["DATUI_GCP_PROJECT"],
549 EnvGroup::Datui,
550 "The Google Cloud project to list when projects cannot be searched. Like `GOOGLE_CLOUD_PROJECT`, but read first",
551 ),
552 env(
553 &["DATUI_TRACE_FIRST_ROWS"],
554 EnvGroup::Datui,
555 "A file to write the time (in Unix nanoseconds) to once the first rows are drawn. For benchmarks",
556 ),
557 env(
558 &["NO_COLOR"],
559 EnvGroup::Terminal,
560 "Set to anything to turn off colors: the terminal's own are used for everything",
561 ),
562 env(
563 &["COLORTERM", "TERM", "FORCE_COLOR"],
564 EnvGroup::Terminal,
565 "How many colors the terminal draws: 24-bit, 256 or 16. Theme colors are reduced to fit",
566 ),
567 env(
568 &["COLORFGBG"],
569 EnvGroup::Terminal,
570 "With `theme.mode = \"auto\"`, says whether the background is light or dark, for a terminal that does not answer when asked",
571 ),
572 env(
573 &["TERM_PROGRAM"],
574 EnvGroup::Terminal,
575 "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",
576 ),
577 env(
578 &["LC_ALL", "LC_CTYPE", "LANG"],
579 EnvGroup::Terminal,
580 "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",
581 ),
582 env(
583 &["VISUAL", "EDITOR", "PAGER"],
584 EnvGroup::Programs,
585 "The inspector's `o` opens text in the first one set, else `less` (on Windows, the system's opener)",
586 ),
587 env(
588 &["AWS_PROFILE"],
589 EnvGroup::Cloud,
590 "The AWS profile for `s3://`, else `default`",
591 ),
592 env(
593 &[
594 "AWS_ACCESS_KEY_ID",
595 "AWS_SECRET_ACCESS_KEY",
596 "AWS_SESSION_TOKEN",
597 ],
598 EnvGroup::Cloud,
599 "AWS keys, and the token of temporary ones",
600 ),
601 env(
602 &["AWS_REGION", "AWS_DEFAULT_REGION"],
603 EnvGroup::Cloud,
604 "The AWS region",
605 ),
606 env(
607 &["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"],
608 EnvGroup::Cloud,
609 "An S3-compatible endpoint (MinIO, R2, Ceph); the first one set",
610 ),
611 env(
612 &["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"],
613 EnvGroup::Cloud,
614 "The AWS config and credentials files, in place of `~/.aws/config` and `~/.aws/credentials`",
615 ),
616 env(
617 &[
618 "GOOGLE_APPLICATION_CREDENTIALS",
619 "GOOGLE_SERVICE_ACCOUNT",
620 "GOOGLE_SERVICE_ACCOUNT_PATH",
621 "GOOGLE_SERVICE_ACCOUNT_KEY",
622 ],
623 EnvGroup::Cloud,
624 "A Google Cloud service account or credentials file for `gs://`",
625 ),
626 env(
627 &[
628 "GOOGLE_CLOUD_PROJECT",
629 "GCLOUD_PROJECT",
630 "CLOUDSDK_CORE_PROJECT",
631 "GCP_PROJECT",
632 ],
633 EnvGroup::Cloud,
634 "The Google Cloud project to list buckets in, after `DATUI_GCP_PROJECT`; the first one set",
635 ),
636 env(
637 &["CLOUDSDK_CONFIG"],
638 EnvGroup::Cloud,
639 "The `gcloud` configuration directory, in place of `~/.config/gcloud`",
640 ),
641 env(
642 &["AZURE_STORAGE_CONNECTION_STRING"],
643 EnvGroup::Cloud,
644 "An Azure storage connection string, with `AccountKey` or `SharedAccessSignature`",
645 ),
646 env(
647 &[
648 "AZURE_STORAGE_ACCOUNT_NAME",
649 "AZURE_STORAGE_ACCOUNT_KEY",
650 "AZURE_STORAGE_SAS_TOKEN",
651 ],
652 EnvGroup::Cloud,
653 "An Azure storage account and its key or SAS token",
654 ),
655 env(
656 &[
657 "AZURE_TENANT_ID",
658 "AZURE_CLIENT_ID",
659 "AZURE_CLIENT_SECRET",
660 "AZURE_FEDERATED_TOKEN_FILE",
661 ],
662 EnvGroup::Cloud,
663 "An Azure service principal, or AKS workload identity",
664 ),
665 env(
666 &["AZURE_CONFIG_DIR"],
667 EnvGroup::Cloud,
668 "The Azure CLI's directory, in place of `~/.azure`",
669 ),
670];
671
672pub fn render_environment_markdown() -> String {
674 let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
675 let mut out = String::from(
676 "# Environment variables\n\n\
677 <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs`. Do not edit. -->\n\n\
678 The variables datui reads.\n",
679 );
680 for group in [
681 EnvGroup::Datui,
682 EnvGroup::Terminal,
683 EnvGroup::Programs,
684 EnvGroup::Cloud,
685 ] {
686 out.push_str(&format!("\n## {}\n\n", group.title()));
687 if group == EnvGroup::Cloud {
688 out.push_str(
689 "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",
690 );
691 }
692 out.push_str("| Variable | What it does |\n|---|---|\n");
693 for var in ENVIRONMENT.iter().filter(|v| v.group == group) {
694 let names: Vec<String> = var.names.iter().map(|n| format!("`{n}`")).collect();
695 out.push_str(&format!("| {} | {} |\n", names.join(", "), cell(var.doc)));
696 }
697 }
698 out
699}
700
701pub fn find(key: &str) -> Option<&'static Setting> {
703 SETTINGS.iter().find(|s| s.matches(key))
704}
705
706pub fn flag_help(flag: &str) -> String {
709 let setting = by_flag(flag).unwrap_or_else(|| panic!("--{flag} sets no registered key"));
710 format!("{} [config: {}]", setting.doc, setting.key)
711}
712
713pub fn by_kwarg(kwarg: &str) -> Option<&'static Setting> {
715 SETTINGS.iter().find(|s| s.kwarg == Some(kwarg))
716}
717
718pub fn by_flag(flag: &str) -> Option<&'static Setting> {
720 SETTINGS.iter().find(|s| s.flag == Some(flag))
721}
722
723pub fn in_section(section: &str) -> impl Iterator<Item = &'static Setting> + '_ {
725 SETTINGS.iter().filter(move |s| s.section() == section)
726}
727
728pub const TYPES: &[(&str, &str)] = &[
731 (
732 "size",
733 "A number and a unit: `512MiB`, `2GiB`, `100KiB` (`MB`, `GB` are powers of 1000). `0` needs none",
734 ),
735 ("duration", "A number and a unit: `250ms`, `1.5s`, `2m`"),
736 (
737 "list",
738 "In a file, a TOML array; with `-c`, `a,b` or the array",
739 ),
740 (
741 "color",
742 "A name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`",
743 ),
744];
745
746pub fn render_settings_markdown() -> String {
749 let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
750 let mut out = String::from(
751 "# Settings\n\n\
752 <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs settings`. Do not edit. -->\n\n\
753 Set these in `config.toml` (`datui config init` writes one with every key\n\
754 commented out), or for one run with `-c KEY=VALUE`:\n\n\
755 ```bash\n\
756 printf 'a,b\\n1,2\\n' | datui -c display.row_numbers=true\n\
757 ```\n\n\
758 A flag beats `-c`, which beats the config files, which beat the defaults.\n\
759 `datui config keys` lists every key with its value in effect and where it was\n\
760 set. See [Configure datui](../user-guide/configuration.md) for where the file\n\
761 lives, imports, the theme and troubleshooting.\n\n\
762 | Type | Written as |\n\
763 |---|---|\n",
764 );
765 for (kind, written) in TYPES {
766 out.push_str(&format!("| {kind} | {written} |\n"));
767 }
768 for section in SECTIONS {
769 let settings: Vec<&Setting> = in_section(section.name).collect();
770 if settings.is_empty() {
771 continue;
772 }
773 out.push_str(&format!("\n## {}\n\n", section.title));
774 if !section.name.is_empty() {
775 out.push_str(&format!("`[{}]`", section.name));
776 if !section.intro.is_empty() {
777 out.push_str(&format!(" {}", section.intro));
778 }
779 out.push_str("\n\n");
780 } else if !section.intro.is_empty() {
781 out.push_str(&format!("{}\n\n", section.intro));
782 }
783 let colors = settings
784 .iter()
785 .all(|s| matches!(s.default, DefaultValue::Color { .. }));
786 if colors {
787 out.push_str("| Key | Dark | Light | Description |\n|---|---|---|---|\n");
788 } else {
789 out.push_str("| Key | Type | Default | Flag | Description |\n|---|---|---|---|---|\n");
790 }
791 for setting in settings {
792 let key = format!("`{}`", setting.key);
793 match setting.default {
794 DefaultValue::Color { dark, light } => out.push_str(&format!(
795 "| {key} | `{dark}` | `{light}` | {} |\n",
796 cell(setting.doc)
797 )),
798 DefaultValue::Value(v) | DefaultValue::Unset(v) => {
799 let default = match setting.default {
800 DefaultValue::Value(_) => format!("`{}`", cell(v)),
801 _ => "unset".to_string(),
802 };
803 let flag = setting.flag.map(|f| format!("`--{f}`")).unwrap_or_default();
804 out.push_str(&format!(
805 "| {key} | {} | {default} | {flag} | {} |\n",
806 setting.kind.describe(),
807 cell(setting.doc)
808 ));
809 }
810 }
811 }
812 }
813 out.push_str(
814 "\nThe environment variables datui reads are in\n\
815 [Environment variables](environment.md).\n",
816 );
817 out
818}
819
820#[derive(Debug, Clone, PartialEq)]
823pub struct Override {
824 pub key: String,
825 pub value: toml::Value,
826}
827
828impl std::str::FromStr for Override {
829 type Err = String;
830
831 fn from_str(text: &str) -> Result<Self, String> {
834 let Some((key, value)) = text.split_once('=') else {
835 return Err(format!(
836 "\"{text}\" is not KEY=VALUE, as in -c display.row_numbers=true"
837 ));
838 };
839 let key = key.trim();
840 let setting = find(key).ok_or_else(|| unknown_key(key))?;
841 let value = parse_value(setting, value).map_err(|e| {
842 format!(
843 "{key}: {e} ({key} takes {})",
844 setting.kind.describe().replace("\\|", "|")
845 )
846 })?;
847 Ok(Self {
848 key: key.to_string(),
849 value,
850 })
851 }
852}
853
854pub fn parse_value(setting: &Setting, text: &str) -> Result<toml::Value, String> {
856 let trimmed = text.trim();
857 Ok(match setting.kind {
858 Bool => toml::Value::Boolean(parse_bool(trimmed)?),
859 Count => {
860 let n: u64 = trimmed
861 .replace('_', "")
862 .parse()
863 .map_err(|_| format!("\"{trimmed}\" is not a whole number"))?;
864 toml::Value::Integer(i64::try_from(n).map_err(|_| format!("{n} is too large"))?)
865 }
866 Text | Path | Color => toml::Value::String(text.to_string()),
867 List => {
868 if trimmed.starts_with('[') {
869 toml_value(trimmed)
870 .filter(toml::Value::is_array)
871 .ok_or_else(|| format!("\"{trimmed}\" is not a list"))?
872 } else {
873 toml::Value::Array(
874 trimmed
875 .split(',')
876 .map(str::trim)
877 .filter(|item| !item.is_empty())
878 .map(|item| toml::Value::String(item.to_string()))
879 .collect(),
880 )
881 }
882 }
883 Choice(words) => {
884 let word = trimmed.to_ascii_lowercase();
885 if !words.contains(&word.as_str()) {
886 return Err(format!("\"{trimmed}\" is not one of {}", words.join(", ")));
887 }
888 toml::Value::String(word)
889 }
890 Size => {
891 crate::units::parse_size(trimmed)?;
892 toml::Value::String(trimmed.to_string())
893 }
894 Duration => {
895 crate::units::parse_duration(trimmed)?;
896 toml::Value::String(trimmed.to_string())
897 }
898 Toml(_) => toml_value(trimmed).unwrap_or_else(|| toml::Value::String(trimmed.to_string())),
900 Tables => return Err("is a list of tables; write it in a config file".into()),
901 })
902}
903
904pub fn parse_bool(text: &str) -> Result<bool, String> {
906 match text.to_ascii_lowercase().as_str() {
907 "true" | "yes" | "on" | "1" => Ok(true),
908 "false" | "no" | "off" | "0" => Ok(false),
909 _ => Err(format!("\"{text}\" is not true or false")),
910 }
911}
912
913fn toml_value(text: &str) -> Option<toml::Value> {
914 format!("v = {text}")
915 .parse::<toml::Table>()
916 .ok()
917 .and_then(|mut t| t.remove("v"))
918}
919
920fn unknown_key(key: &str) -> String {
922 let near = suggestions(key);
923 let mut message = format!("\"{key}\" is not a config key");
924 if !near.is_empty() {
925 message.push_str(&format!("; did you mean {}?", near.join(" or ")));
926 }
927 message.push_str(" `datui config keys` lists them");
928 message
929}
930
931pub fn suggestions(key: &str) -> Vec<&'static str> {
934 let name = key.rsplit_once('.').map_or(key, |(_, n)| n);
935 let mut scored: Vec<(usize, &'static str)> = SETTINGS
936 .iter()
937 .filter(|s| !s.key.ends_with(".*"))
938 .filter_map(|s| {
939 let distance = edit_distance(key, s.key);
940 let close = distance <= (key.len() / 4).max(2);
941 let other = s.name();
942 let alike = other == name
943 || (other.len() >= 4 && name.contains(other))
944 || (name.len() >= 4 && other.contains(name));
945 (close || alike).then_some((distance, s.key))
946 })
947 .collect();
948 scored.sort();
949 scored.into_iter().take(3).map(|(_, k)| k).collect()
950}
951
952fn edit_distance(a: &str, b: &str) -> usize {
954 let b: Vec<char> = b.chars().collect();
955 let mut row: Vec<usize> = (0..=b.len()).collect();
956 for (i, ca) in a.chars().enumerate() {
957 let mut diagonal = row[0];
958 row[0] = i + 1;
959 for (j, cb) in b.iter().enumerate() {
960 let above = row[j + 1];
961 row[j + 1] = (diagonal + usize::from(ca != *cb))
962 .min(above + 1)
963 .min(row[j] + 1);
964 diagonal = above;
965 }
966 }
967 row[b.len()]
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973
974 #[test]
975 fn an_override_reads_its_value_for_the_key() {
976 let o: Override = "display.mouse=yes".parse().unwrap();
977 assert_eq!(o.value, toml::Value::Boolean(true));
978 let o: Override = "display.row_numbers=auto".parse().unwrap();
979 assert_eq!(o.value, toml::Value::String("auto".into()));
980 let o: Override = "display.row_numbers=false".parse().unwrap();
981 assert_eq!(o.value, toml::Value::Boolean(false));
982 let o: Override = "csv.comment=#".parse().unwrap();
983 assert_eq!(o.value, toml::Value::String("#".into()));
984 let o: Override = "cloud.env_files=a.env, b.env".parse().unwrap();
985 assert_eq!(o.value.as_array().map(Vec::len), Some(2));
986 let o: Override = "home.hide=[\"x\"]".parse().unwrap();
987 assert_eq!(o.value.as_array().map(Vec::len), Some(1));
988 let o: Override = "cloud.discover=s3,gcs".parse().unwrap();
989 assert_eq!(o.value, toml::Value::String("s3,gcs".into()));
990 let o: Override = "cloud.discover=false".parse().unwrap();
991 assert_eq!(o.value, toml::Value::Boolean(false));
992 let o: Override = "glyphs.spinner=[\"a\", \"b\"]".parse().unwrap();
993 assert!(o.value.is_array());
994 let o: Override = "csv.null_values=amount=".parse().unwrap();
996 assert_eq!(o.value.as_array().unwrap()[0].as_str(), Some("amount="));
997 }
998
999 #[test]
1000 fn sizes_and_durations_take_their_unit() {
1001 let o: Override = "performance.max_buffered=1GiB".parse().unwrap();
1002 assert_eq!(o.value, toml::Value::String("1GiB".into()));
1003 let e = "performance.max_buffered=512"
1004 .parse::<Override>()
1005 .unwrap_err();
1006 assert!(e.contains("needs a unit"), "{e}");
1007 let o: Override = "read.follow_interval=1s".parse().unwrap();
1008 assert_eq!(o.value, toml::Value::String("1s".into()));
1009 let e = "read.follow_interval=fast".parse::<Override>().unwrap_err();
1010 assert!(e.contains("duration"), "{e}");
1011 }
1012
1013 #[test]
1014 fn an_override_refuses_with_the_way_out() {
1015 let e = "file_loading.comment_char=#"
1016 .parse::<Override>()
1017 .unwrap_err();
1018 assert!(e.contains("did you mean csv.comment"), "{e}");
1019 let e = "performance.polars_streaming=false"
1020 .parse::<Override>()
1021 .unwrap_err();
1022 assert!(e.contains("performance.streaming"), "{e}");
1023 let e = "display.row_number=true".parse::<Override>().unwrap_err();
1024 assert!(e.contains("did you mean display.row_numbers"), "{e}");
1025 let e = "row_numbers=true".parse::<Override>().unwrap_err();
1026 assert!(e.contains("display.row_numbers"), "{e}");
1027 let e = "display.row_numbers_start=one"
1028 .parse::<Override>()
1029 .unwrap_err();
1030 assert!(
1031 e.contains("not a whole number") && e.contains("integer"),
1032 "{e}"
1033 );
1034 let e = "display.mouse=maybe".parse::<Override>().unwrap_err();
1035 assert!(e.contains("not true or false"), "{e}");
1036 let e = "display.unicode=sometimes".parse::<Override>().unwrap_err();
1037 assert!(e.contains("auto, always, never"), "{e}");
1038 let e = "cloud.connections=x".parse::<Override>().unwrap_err();
1039 assert!(e.contains("config file"), "{e}");
1040 let e = "display.mouse".parse::<Override>().unwrap_err();
1041 assert!(e.contains("KEY=VALUE"), "{e}");
1042 let e = "nothing.like.this=1".parse::<Override>().unwrap_err();
1043 assert!(e.contains("config keys"), "{e}");
1044 }
1045
1046 #[test]
1047 fn every_key_is_listed_once_in_a_known_section() {
1048 for (i, setting) in SETTINGS.iter().enumerate() {
1049 assert!(
1050 SECTIONS.iter().any(|s| s.name == setting.section()),
1051 "{} has no section",
1052 setting.key
1053 );
1054 assert!(
1055 SETTINGS[..i].iter().all(|other| other.key != setting.key),
1056 "{} is listed twice",
1057 setting.key
1058 );
1059 assert!(!setting.doc.is_empty(), "{} has no doc", setting.key);
1060 }
1061 }
1062
1063 #[test]
1064 fn a_wildcard_key_matches_one_name_below_it() {
1065 let glyph = find("glyphs.spinner").expect("a glyph slot");
1066 assert_eq!(glyph.key, "glyphs.*");
1067 assert!(find("glyphs").is_none());
1068 assert!(find("glyphs.a.b").is_none());
1069 assert_eq!(find("display.mouse").map(|s| s.key), Some("display.mouse"));
1070 }
1071}