Skip to content

Commit ce13564

Browse files
calliclesclaude
andcommitted
Make UI colors theme-safe by default and configurable via [theme]
De-emphasized text now uses the faint attribute instead of DarkGray, and status colors move to the bright ANSI range, so the UI stays legible on terminal themes with mid-toned backgrounds like macOS Terminal's "Ocean" (#25). Past-TTL becomes orange to stay distinct from the brighter error red. Every color role (accent, agree, differ, error, pending, stale, upstream, muted, coastline, grid) can be overridden from a [theme] table in the config file, accepting ANSI names, 256-color indexes, or hex. The muted role also accepts "faint" (the default) as an escape hatch works the other way: terminals that render faint poorly can set a fixed color instead. Fixes #25 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a4c0cb commit ce13564

7 files changed

Lines changed: 387 additions & 94 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2626
releases can be pinned via git tag (`github:514-labs/dnsglobe/v0.3.0`).
2727
A `devbox.json` is included for reproducible development environments.
2828
([#10](https://github.com/514-labs/dnsglobe/issues/10))
29+
- Custom color themes: a `[theme]` table in the config file recolors any UI
30+
role (`accent`, `agree`, `differ`, `error`, `pending`, `stale`,
31+
`upstream`, `muted`, `coastline`, `grid`) using ANSI color names,
32+
256-color indexes, or hex values.
33+
([#27](https://github.com/514-labs/dnsglobe/pull/27))
34+
35+
### Changed
36+
37+
- The default palette now stays legible on terminal themes with mid-toned
38+
backgrounds, like macOS Terminal's "Ocean": de-emphasized text uses the
39+
faint attribute instead of dark gray (dimming your theme's own foreground
40+
color, which is always readable), and status colors moved to the bright
41+
ANSI range. Past-TTL is now orange to stay distinct from the brighter
42+
error red. Set `muted = "darkgray"` in `[theme]` to restore the old
43+
de-emphasis on terminals that render faint poorly.
44+
([#25](https://github.com/514-labs/dnsglobe/issues/25),
45+
[#27](https://github.com/514-labs/dnsglobe/pull/27))
2946

3047
## [0.3.1] - 2026-07-06
3148

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,28 @@ lon = -74.0 # omit both to leave it off the map
9797
[[resolvers]]
9898
name = "NS1 (public)"
9999
ip = "198.51.100.53"
100+
101+
# Optionally recolor the UI. Every key is optional; unset roles keep their
102+
# defaults. Colors are ANSI names ("lightcyan"), 256-color indexes ("208"),
103+
# or hex ("#ff8700" — needs truecolor support).
104+
[theme]
105+
accent = "lightcyan" # borders, titles, cursor, anycast sites
106+
agree = "lightgreen" # answers matching the majority; fast latency
107+
differ = "lightmagenta" # answers disagreeing with the majority
108+
error = "lightred" # ERR / SERVFAIL / NONE; slow latency
109+
pending = "lightyellow" # queries in flight; middling latency
110+
stale = "208" # caches serving an answer past its own TTL
111+
upstream = "lightblue" # refetched but upstream still has the old data
112+
muted = "faint" # labels, hints, countdowns, quiet borders —
113+
# "faint" dims your terminal's default foreground;
114+
# set a color if your terminal renders faint poorly
115+
coastline = "gray" # map/globe land outline
116+
grid = "244" # globe graticule and limb
100117
```
101118

102-
Invalid config (bad IP, unknown key, `lat` without `lon`, `replace = true`
103-
with no resolvers) is reported at startup with the offending entry named.
119+
Invalid config (bad IP, unknown key, unrecognized color, `lat` without
120+
`lon`, `replace = true` with no resolvers) is reported at startup with the
121+
offending entry named.
104122

105123
## Notes
106124

demo/demo.gif

3.41 MB
Loading

src/config.rs

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Optional TOML config file that adds resolvers to the built-in list, or
22
//! replaces the list entirely (e.g. to check propagation across your own
3-
//! infrastructure, or an internal split-horizon zone).
3+
//! infrastructure, or an internal split-horizon zone), and recolors the UI
4+
//! via a `[theme]` table.
45
//!
56
//! Looked up at `$DNSGLOBE_CONFIG`, else `$XDG_CONFIG_HOME/dnsglobe/config.toml`,
67
//! else `~/.config/dnsglobe/config.toml`. A missing default file just means
@@ -14,6 +15,7 @@ use serde::Deserialize;
1415

1516
use crate::app::ViewMode;
1617
use crate::resolvers::{self, Resolver};
18+
use crate::theme::{self, Theme};
1719

1820
#[derive(Debug, Default, Deserialize)]
1921
#[serde(deny_unknown_fields)]
@@ -25,9 +27,29 @@ pub struct Config {
2527
/// Preferred map panel style; the --view flag overrides it.
2628
view: Option<ViewMode>,
2729
#[serde(default)]
30+
theme: ThemeTable,
31+
#[serde(default)]
2832
resolvers: Vec<Entry>,
2933
}
3034

35+
/// Raw `[theme]` colors as written in the file; validated into a
36+
/// `theme::Theme` by `build_theme`. Every key is optional — unset roles keep
37+
/// their defaults, so a theme can adjust a single color.
38+
#[derive(Debug, Default, Deserialize)]
39+
#[serde(deny_unknown_fields)]
40+
struct ThemeTable {
41+
accent: Option<String>,
42+
agree: Option<String>,
43+
differ: Option<String>,
44+
error: Option<String>,
45+
pending: Option<String>,
46+
stale: Option<String>,
47+
upstream: Option<String>,
48+
muted: Option<String>,
49+
coastline: Option<String>,
50+
grid: Option<String>,
51+
}
52+
3153
#[derive(Debug, Deserialize)]
3254
#[serde(deny_unknown_fields)]
3355
struct Entry {
@@ -46,13 +68,15 @@ struct Entry {
4668
pub struct Settings {
4769
pub resolvers: Vec<Resolver>,
4870
pub view: Option<ViewMode>,
71+
pub theme: Theme,
4972
}
5073

5174
impl Settings {
5275
fn defaults() -> Self {
5376
Self {
5477
resolvers: resolvers::defaults(),
5578
view: None,
79+
theme: Theme::default(),
5680
}
5781
}
5882
}
@@ -76,12 +100,18 @@ pub fn load() -> Result<Settings> {
76100
return Err(err).with_context(|| format!("reading config file {}", path.display()));
77101
}
78102
};
79-
let config: Config =
103+
let mut config: Config =
80104
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
81105
let view = config.view;
106+
let theme = build_theme(std::mem::take(&mut config.theme))
107+
.with_context(|| format!("invalid config {}", path.display()))?;
82108
let resolvers =
83109
resolver_list(config).with_context(|| format!("invalid config {}", path.display()))?;
84-
Ok(Settings { resolvers, view })
110+
Ok(Settings {
111+
resolvers,
112+
view,
113+
theme,
114+
})
85115
}
86116

87117
fn default_path() -> Option<PathBuf> {
@@ -92,6 +122,31 @@ fn default_path() -> Option<PathBuf> {
92122
Some(base.join("dnsglobe").join("config.toml"))
93123
}
94124

125+
/// Overlay the config's `[theme]` colors on the defaults, erroring with the
126+
/// offending key so a typo'd color is easy to find in the file.
127+
fn build_theme(table: ThemeTable) -> Result<Theme> {
128+
let mut out = Theme::default();
129+
for (key, value, slot) in [
130+
("accent", table.accent, &mut out.accent),
131+
("agree", table.agree, &mut out.agree),
132+
("differ", table.differ, &mut out.differ),
133+
("error", table.error, &mut out.error),
134+
("pending", table.pending, &mut out.pending),
135+
("stale", table.stale, &mut out.stale),
136+
("upstream", table.upstream, &mut out.upstream),
137+
("coastline", table.coastline, &mut out.coastline),
138+
("grid", table.grid, &mut out.grid),
139+
] {
140+
if let Some(value) = value {
141+
*slot = theme::parse_color(&value).with_context(|| format!("theme.{key}"))?;
142+
}
143+
}
144+
if let Some(value) = table.muted {
145+
out.muted = theme::parse_muted(&value).context("theme.muted")?;
146+
}
147+
Ok(out)
148+
}
149+
95150
/// Validate the config and merge it with the built-in list.
96151
fn resolver_list(config: Config) -> Result<Vec<Resolver>> {
97152
let mut list = if config.replace {
@@ -259,6 +314,48 @@ mod tests {
259314
assert!(toml::from_str::<Config>("view = \"sphere\"").is_err());
260315
}
261316

317+
fn theme(toml_text: &str) -> Result<Theme> {
318+
let config: Config = toml::from_str(toml_text)?;
319+
build_theme(config.theme)
320+
}
321+
322+
#[test]
323+
fn missing_or_empty_theme_keeps_the_defaults() {
324+
assert_eq!(theme("").unwrap(), Theme::default());
325+
assert_eq!(theme("[theme]").unwrap(), Theme::default());
326+
}
327+
328+
#[test]
329+
fn theme_overrides_only_the_given_roles() {
330+
let theme = theme(
331+
r##"
332+
[theme]
333+
accent = "#ff8700"
334+
muted = "darkgray"
335+
"##,
336+
)
337+
.unwrap();
338+
assert_eq!(theme.accent, ratatui::style::Color::Rgb(0xff, 0x87, 0x00));
339+
assert_eq!(
340+
theme.muted,
341+
crate::theme::Muted::Color(ratatui::style::Color::DarkGray)
342+
);
343+
assert_eq!(theme.agree, Theme::default().agree);
344+
}
345+
346+
#[test]
347+
fn bad_theme_color_errors_with_the_key_name() {
348+
let err = theme("[theme]\nstale = \"ornage\"").unwrap_err();
349+
let chain = format!("{err:#}");
350+
assert!(chain.contains("theme.stale"), "{chain}");
351+
assert!(chain.contains("\"ornage\""), "{chain}");
352+
}
353+
354+
#[test]
355+
fn unknown_theme_keys_are_rejected_to_catch_typos() {
356+
assert!(toml::from_str::<Config>("[theme]\naccnt = \"red\"").is_err());
357+
}
358+
262359
#[test]
263360
fn unknown_keys_are_rejected_to_catch_typos() {
264361
assert!(toml::from_str::<Config>("replase = true").is_err());

src/main.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod dns;
44
mod globe;
55
mod resolvers;
66
mod sites;
7+
mod theme;
78
mod ui;
89
mod world_data;
910

@@ -35,7 +36,14 @@ Configuration:
3536
ip = \"10.0.0.53\" # required, IPv4 or IPv6
3637
location = \"HQ\" # optional; shown in the Loc column
3738
lat = 40.7 # optional map position;
38-
lon = -74.0 # give both or neither";
39+
lon = -74.0 # give both or neither
40+
41+
[theme] # optional; override any UI color role
42+
# accent = \"lightcyan\" # roles: accent, agree, differ, error, pending,
43+
# stale = \"208\" # stale, upstream, muted, coastline, grid
44+
# muted = \"faint\" # colors: ANSI names (\"lightred\"), 256-color
45+
# # indexes (\"208\"), or hex (\"#ff8700\"); `muted`
46+
# # also takes \"faint\" (dim the default foreground)";
3947

4048
/// Global DNS propagation checker TUI — watch a DNS record propagate across
4149
/// public resolvers worldwide, on a world map in your terminal.
@@ -86,6 +94,7 @@ async fn main() -> Result<()> {
8694
let settings = config::load()?;
8795
let view = cli.view.or(settings.view).unwrap_or_default();
8896
resolvers::init(settings.resolvers);
97+
theme::init(settings.theme);
8998

9099
// `--once` runs a single check and prints plain text — handy for scripts
91100
// and for testing without a TTY.

0 commit comments

Comments
 (0)