Repository: fujiapple852/trippy (language: rust)

Issue:
Locale data not copied into docker image
The trippy 0.12.0 Docker image does not include the `locales` directory and as a result no locales are available.

```shell
$ docker run -it fujiapple/trippy 1.1.1.1 --print-locales
TUI locales:
```

<img width="850" alt="Screenshot 2024-12-14 at 12 57 20 AM" src="https://github.com/user-attachments/assets/236c1b88-359a-4266-b3b6-07fce7023fab" />



Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index f5243dd9..7017e7fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -102,12 +102,6 @@ version = "1.0.94"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7"
 
-[[package]]
-name = "arc-swap"
-version = "1.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
-
 [[package]]
 name = "arrayvec"
 version = "0.7.6"
@@ -170,18 +164,6 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
-[[package]]
-name = "base62"
-version = "2.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48fa474cf7492f9a299ba6019fb99ec673e1739556d48e8a90eabaea282ef0e4"
-
-[[package]]
-name = "bitflags"
-version = "1.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-
 [[package]]
 name = "bitflags"
 version = "2.6.0"
@@ -201,16 +183,6 @@ dependencies = [
  "piper",
 ]
 
-[[package]]
-name = "bstr"
-version = "1.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22"
-dependencies = [
- "memchr",
- "serde",
-]
-
 [[package]]
 name = "bumpalo"
 version = "3.16.0"
@@ -487,7 +459,7 @@ version = "0.28.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
 dependencies = [
- "bitflags 2.6.0",
+ "bitflags",
  "crossterm_winapi",
  "mio",
  "parking_lot",
@@ -868,36 +840,6 @@ version = "0.31.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
 
-[[package]]
-name = "glob"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
-
-[[package]]
-name = "globset"
-version = "0.4.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19"
-dependencies = [
- "aho-corasick",
- "bstr",
- "log",
- "regex-automata 0.4.9",
- "regex-syntax 0.8.5",
-]
-
-[[package]]
-name = "globwalk"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc"
-dependencies = [
- "bitflags 1.3.2",
- "ignore",
- "walkdir",
-]
-
 [[package]]
 name = "hashbrown"
 version = "0.15.2"
@@ -1168,22 +1110,6 @@ dependencies = [
  "icu_properties",
 ]
 
-[[package]]
-name = "ignore"
-version = "0.4.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b"
-dependencies = [
- "crossbeam-deque",
- "globset",
- "log",
- "memchr",
- "regex-automata 0.4.9",
- "same-file",
- "walkdir",
- "winapi-util",
-]
-
 [[package]]
 name = "indexmap"
 version = "2.7.0"
@@ -1260,15 +1186,6 @@ version = "1.70.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
-[[package]]
-name = "itertools"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
-dependencies = [
- "either",
-]
-
 [[package]]
 name = "itertools"
 version = "0.13.0"
@@ -1316,12 +1233,6 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
-[[package]]
-name = "libyml"
-version = "0.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64804cc6a5042d4f05379909ba25b503ec04e2c082151d62122d5dcaa274b961"
-
 [[package]]
 name = "linked-hash-map"
 version = "0.5.6"
@@ -1469,22 +1380,13 @@ version = "0.29.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
 dependencies = [
- "bitflags 2.6.0",
+ "bitflags",
  "cfg-if",
  "cfg_aliases",
  "libc",
  "memoffset",
 ]
 
-[[package]]
-name = "normpath"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed"
-dependencies = [
- "windows-sys 0.59.0",
-]
-
 [[package]]
 name = "nu-ansi-term"
 version = "0.46.0"
@@ -1704,13 +1606,13 @@ version = "0.29.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
 dependencies = [
- "bitflags 2.6.0",
+ "bitflags",
  "cassowary",
  "compact_str",
  "crossterm",
  "indoc",
  "instability",
- "itertools 0.13.0",
+ "itertools",
  "lru",
  "paste",
  "strum",
@@ -1725,7 +1627,7 @@ version = "0.5.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f"
 dependencies = [
- "bitflags 2.6.0",
+ "bitflags",
 ]
 
 [[package]]
@@ -1788,60 +1690,6 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3"
 
-[[package]]
-name = "rust-i18n"
-version = "3.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "039f57d22229db401af3458ca939300178e99e88b938573cea12b7c2b0f09724"
-dependencies = [
- "globwalk",
- "once_cell",
- "regex",
- "rust-i18n-macro",
- "rust-i18n-support",
- "smallvec",
-]
-
-[[package]]
-name = "rust-i18n-macro"
-version = "3.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dde5c022360a2e54477882843d56b6f9bcb4bc62f504b651a2f497f0028d174f"
-dependencies = [
- "glob",
- "once_cell",
- "proc-macro2",
- "quote",
- "rust-i18n-support",
- "serde",
- "serde_json",
- "serde_yml",
- "syn 2.0.90",
-]
-
-[[package]]
-name = "rust-i18n-support"
-version = "3.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75d2844d36f62b5d6b66f9cf8f8cbdbbbdcdb5fd37a473a9cc2fb45fdcf485d2"
-dependencies = [
- "arc-swap",
- "base62",
- "globwalk",
- "itertools 0.11.0",
- "lazy_static",
- "normpath",
- "once_cell",
- "proc-macro2",
- "regex",
- "serde",
- "serde_json",
- "serde_yml",
- "siphasher",
- "toml 0.7.8",
- "triomphe",
-]
-
 [[package]]
 name = "rustc-demangle"
 version = "0.1.24"
@@ -1854,7 +1702,7 @@ version = "0.38.42"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85"
 dependencies = [
- "bitflags 2.6.0",
+ "bitflags",
  "errno",
  "libc",
  "linux-raw-sys",
@@ -1873,15 +1721,6 @@ version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
 
-[[package]]
-name = "same-file"
-version = "1.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
-dependencies = [
- "winapi-util",
-]
-
 [[package]]
 name = "scopeguard"
 version = "1.2.0"
@@ -1952,23 +1791,6 @@ dependencies = [
  "syn 2.0.90",
 ]
 
-[[package]]
-name = "serde_yml"
-version = "0.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48e76bab63c3fd98d27c17f9cbce177f64a91f5e69ac04cafe04e1bb25d1dc3c"
-dependencies = [
- "indexmap",
- "itoa",
- "libyml",
- "log",
- "memchr",
- "ryu",
- "serde",
- "serde_json",
- "tempfile",
-]
-
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -2020,12 +1842,6 @@ version = "2.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e"
 
-[[package]]
-name = "siphasher"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
-
 [[package]]
 name = "slab"
 version = "0.4.9"
@@ -2133,19 +1949,6 @@ dependencies = [
  "libc",
 ]
 
-[[package]]
-name = "tempfile"
-version = "3.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c"
-dependencies = [
- "cfg-if",
- "fastrand",
- "once_cell",
- "rustix",
- "windows-sys 0.59.0",
-]
-
 [[package]]
 name = "terminal_size"
 version = "0.4.1"
@@ -2312,18 +2115,6 @@ dependencies = [
  "tokio",
 ]
 
-[[package]]
-name = "toml"
-version = "0.7.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
-dependencies = [
- "serde",
- "serde_spanned",
- "toml_datetime",
- "toml_edit 0.19.15",
-]
-
 [[package]]
 name = "toml"
 version = "0.8.19"
@@ -2333,7 +2124,7 @@ dependencies = [
  "serde",
  "serde_spanned",
  "toml_datetime",
- "toml_edit 0.22.22",
+ "toml_edit",
 ]
 
 [[package]]
@@ -2345,19 +2136,6 @@ dependencies = [
  "serde",
 ]
 
-[[package]]
-name = "toml_edit"
-version = "0.19.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
-dependencies = [
- "indexmap",
- "serde",
- "serde_spanned",
- "toml_datetime",
- "winnow 0.5.40",
-]
-
 [[package]]
 name = "toml_edit"
 version = "0.22.22"
@@ -2368,7 +2146,7 @@ dependencies = [
  "serde",
  "serde_spanned",
  "toml_datetime",
- "winnow 0.6.20",
+ "winnow",
 ]
 
 [[package]]
@@ -2377,7 +2155,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "clap",
- "itertools 0.13.0",
+ "itertools",
  "trippy",
 ]
 
@@ -2466,17 +2244,6 @@ dependencies = [
  "tracing-serde",
 ]
 
-[[package]]
-name = "triomphe"
-version = "0.1.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85"
-dependencies = [
- "arc-swap",
- "serde",
- "stable_deref_trait",
-]
-
 [[package]]
 name = "trippy"
 version = "0.13.0-dev"
@@ -2495,12 +2262,12 @@ version = "0.13.0-dev"
 dependencies = [
  "anyhow",
  "arrayvec",
- "bitflags 2.6.0",
+ "bitflags",
  "derive_more",
  "hex-literal",
  "indexmap",
  "ipnetwork",
- "itertools 0.13.0",
+ "itertools",
  "mockall",
  "nix",
  "parking_lot",
@@ -2512,7 +2279,7 @@ dependencies = [
  "thiserror 2.0.6",
  "tokio",
  "tokio-util",
- "toml 0.8.19",
+ "toml",
  "tracing",
  "tracing-subscriber",
  "trippy-packet",
@@ -2530,7 +2297,7 @@ dependencies = [
  "crossbeam",
  "dns-lookup",
  "hickory-resolver",
- "itertools 0.13.0",
+ "itertools",
  "parking_lot",
  "thiserror 2.0.6",
 ]
@@ -2541,7 +2308,7 @@ version = "0.13.0-dev"
 dependencies = [
  "anyhow",
  "hex-literal",
- "itertools 0.13.0",
+ "itertools",
  "thiserror 2.0.6",
 ]
 
@@ -2573,19 +2340,18 @@ dependencies = [
  "etcetera",
  "humantime",
  "insta",
- "itertools 0.13.0",
+ "itertools",
  "maxminddb",
  "petgraph",
  "pretty_assertions",
  "ratatui",
- "rust-i18n",
  "serde",
  "serde_json",
  "serde_with",
  "strum",
  "sys-locale",
  "test-case",
- "toml 0.8.19",
+ "toml",
  "tracing",
  "tracing-chrome",
  "tracing-subscriber",
@@ -2634,7 +2400,7 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
 dependencies = [
- "itertools 0.13.0",
+ "itertools",
  "unicode-segmentation",
  "unicode-width 0.1.14",
 ]
@@ -2686,16 +2452,6 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
 
-[[package]]
-name = "walkdir"
-version = "2.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
-dependencies = [
- "same-file",
- "winapi-util",
-]
-
 [[package]]
 name = "wasi"
 version = "0.11.0+wasi-snapshot-preview1"
@@ -2778,15 +2534,6 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
 
-[[package]]
-name = "winapi-util"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
-dependencies = [
- "windows-sys 0.59.0",
-]
-
 [[package]]
 name = "winapi-x86_64-pc-windows-gnu"
 version = "0.4.0"
@@ -2950,15 +2697,6 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
-[[package]]
-name = "winnow"
-version = "0.5.40"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
-dependencies = [
- "memchr",
-]
-
 [[package]]
 name = "winnow"
 version = "0.6.20"
diff --git a/Cargo.toml b/Cargo.toml
index 220350b6..37a6fb5a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -61,7 +61,6 @@ petgraph = "0.6.5"
 pretty_assertions = "1.4.1"
 rand = "0.8.5"
 ratatui = "0.29.0"
-rust-i18n = "3.1.2"
 serde = { version = "1.0.201", default-features = false }
 serde_json = { version = "1.0.117", default-features = false }
 serde_with = { version = "3.11.0", default-features = false, features = ["macros"] }
diff --git a/Dockerfile b/Dockerfile
index d636dd1d..05093124 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -33,6 +33,8 @@ COPY crates/trippy-core/src /app/crates/trippy-core/src
 COPY crates/trippy-dns/src /app/crates/trippy-dns/src
 COPY crates/trippy-packet/src /app/crates/trippy-packet/src
 COPY crates/trippy-privilege/src /app/crates/trippy-privilege/src
+COPY crates/trippy-tui/build.rs /app/crates/trippy-tui
+COPY crates/trippy-tui/locales.toml /app/crates/trippy-tui
 COPY trippy-config-sample.toml /app
 COPY trippy-config-sample.toml /app/crates/trippy-tui
 COPY README.md /app
diff --git a/crates/trippy-tui/Cargo.toml b/crates/trippy-tui/Cargo.toml
index 2134f2bd..acc48d7d 100644
--- a/crates/trippy-tui/Cargo.toml
+++ b/crates/trippy-tui/Cargo.toml
@@ -41,7 +41,6 @@ itertools.workspace = true
 maxminddb.workspace = true
 petgraph.workspace = true
 ratatui.workspace = true
-rust-i18n.workspace = true
 serde = { workspace = true, default-features = false, features = [ "derive" ] }
 serde_json.workspace = true
 serde_with.workspace = true
@@ -59,4 +58,4 @@ pretty_assertions.workspace = true
 test-case.workspace = true
 
 [lints]
-workspace = true
\ No newline at end of file
+workspace = true
diff --git a/crates/trippy-tui/build.rs b/crates/trippy-tui/build.rs
new file mode 100644
index 00000000..e625f896
--- /dev/null
+++ b/crates/trippy-tui/build.rs
@@ -0,0 +1,3 @@
+pub fn main() {
+    println!("cargo:rerun-if-changed=locales.toml");
+}
diff --git a/crates/trippy-tui/locales/app.toml b/crates/trippy-tui/locales.toml
similarity index 99%
rename from crates/trippy-tui/locales/app.toml
rename to crates/trippy-tui/locales.toml
index c68b0c45..d64ea45b 100644
--- a/crates/trippy-tui/locales/app.toml
+++ b/crates/trippy-tui/locales.toml
@@ -1,5 +1,3 @@
-_version = 2
-
 [trippy]
 en = "trippy"
 fr = "trippy"
diff --git a/crates/trippy-tui/src/app.rs b/crates/trippy-tui/src/app.rs
index 77a09ecd..315f9a11 100644
--- a/crates/trippy-tui/src/app.rs
+++ b/crates/trippy-tui/src/app.rs
@@ -1,7 +1,7 @@
 use crate::config::{LogFormat, LogSpanEvents, Mode, TrippyConfig};
 use crate::frontend::TuiConfig;
 use crate::geoip::GeoIpLookup;
-use crate::locale::{locale, set_locale};
+use crate::locale;
 use crate::{frontend, report};
 use anyhow::{anyhow, Error};
 use std::net::IpAddr;
@@ -15,8 +15,7 @@ use trippy_privilege::Privilege;
 
 /// Run the trippy application.
 pub fn run_trippy(cfg: &TrippyConfig, pid: u16) -> anyhow::Result<()> {
-    set_locale(cfg.tui_locale.as_deref());
-    let locale = locale();
+    let locale = locale::set_locale(cfg.tui_locale.as_deref());
     let _guard = configure_logging(cfg);
     let resolver = start_dns_resolver(cfg)?;
     let geoip_lookup = create_geoip_lookup(cfg, &locale)?;
@@ -30,7 +29,7 @@ pub fn run_trippy(cfg: &TrippyConfig, pid: u16) -> anyhow::Result<()> {
     }
     let traces = start_tracers(cfg, &addrs, pid)?;
     Privilege::drop_privileges()?;
-    run_frontend(cfg, locale, resolver, geoip_lookup, traces)
+    run_frontend(cfg, &locale, resolver, geoip_lookup, traces)
 }
 
 /// Start all tracers.
@@ -88,7 +87,7 @@ fn start_tracer(
 /// Run the TUI, stream or report.
 fn run_frontend(
     args: &TrippyConfig,
-    locale: String,
+    locale: &str,
     resolver: DnsResolver,
     geoip_lookup: GeoIpLookup,
     traces: Vec<TraceInfo>,
@@ -96,7 +95,7 @@ fn run_frontend(
     match args.mode {
         Mode::Tui => frontend::run_frontend(
             traces,
-            make_tui_config(args, locale),
+            make_tui_config(args, locale.to_string()),
             resolver,
             geoip_lookup,
         )?,
diff --git a/crates/trippy-tui/src/frontend/render/bar.rs b/crates/trippy-tui/src/frontend/render/bar.rs
index a0899d4f..7ebcc413 100644
--- a/crates/trippy-tui/src/frontend/render/bar.rs
+++ b/crates/trippy-tui/src/frontend/render/bar.rs
@@ -13,20 +13,17 @@ use trippy_dns::ResolveMethod;
 pub fn render(f: &mut Frame<'_>, rect: Rect, app: &TuiApp) {
     let protocol = Span::raw(match app.tracer_config().data.protocol() {
         Protocol::Icmp => format!(
-            "{}/{}",
+            "{}/ICMP",
             fmt_target_family(app.tracer_config().data.target_addr()),
-            t!("ICMP"),
         ),
         Protocol::Udp => format!(
-            "{}/{}/{}",
+            "{}/UDP/{}",
             fmt_target_family(app.tracer_config().data.target_addr()),
-            t!("UDP"),
             app.tracer_config().data.multipath_strategy(),
         ),
         Protocol::Tcp => format!(
-            "{}/{}",
+            "{}/TCP",
             fmt_target_family(app.tracer_config().data.target_addr()),
-            t!("TCP"),
         ),
     });
 
diff --git a/crates/trippy-tui/src/frontend/render/settings.rs b/crates/trippy-tui/src/frontend/render/settings.rs
index 49fa5e7c..739c002c 100644
--- a/crates/trippy-tui/src/frontend/render/settings.rs
+++ b/crates/trippy-tui/src/frontend/render/settings.rs
@@ -170,8 +170,7 @@ fn format_all_settings(app: &TuiApp) -> Vec<(String, String, Vec<SettingsItem>)>
                 c = toggle_column,
                 d = move_down,
                 u = move_up
-            )
-            .to_string(),
+            ),
             columns_settings,
         ),
     ]
diff --git a/crates/trippy-tui/src/lib.rs b/crates/trippy-tui/src/lib.rs
index 32835ab9..ff0955f2 100644
--- a/crates/trippy-tui/src/lib.rs
+++ b/crates/trippy-tui/src/lib.rs
@@ -20,9 +20,6 @@ mod print;
 mod report;
 mod util;
 
-// initialize the i18n system.
-rust_i18n::i18n!("locales", fallback = "en");
-
 /// Run the Trippy application.
 pub fn trippy() -> anyhow::Result<()> {
     let args = Args::parse();
diff --git a/crates/trippy-tui/src/locale.rs b/crates/trippy-tui/src/locale.rs
index 5739fd0f..3fd0ac7f 100644
--- a/crates/trippy-tui/src/locale.rs
+++ b/crates/trippy-tui/src/locale.rs
@@ -1,45 +1,131 @@
+use itertools::Itertools;
+use serde::Deserialize;
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
 const FALLBACK_LOCALE: &str = "en";
 
 /// Set the locale for the application.
 ///
-/// If the given locale is `None` or unsupported, the system locale is tried. If the system locale
-/// is not supported, the fallback locale is used.
+/// If the given locale is `None` the system locale is tried. If the system locale cannot be
+/// determined then the fallback locale is used.
 ///
-/// In both cases, the language part of the locale is used if the full locale is not supported.
-pub fn set_locale(locale: Option<&str>) {
-    if let Some(locale) = locale {
-        set_locale_inner(locale);
-    } else if let Some(locale) = sys_locale::get_locale().as_ref() {
-        set_locale_inner(locale);
+/// In all cases, the language part of the locale is used if the full locale is not supported.
+pub fn set_locale(locale: Option<&str>) -> String {
+    let new_locale = calculate_locale(locale, sys_locale::get_locale().as_deref());
+    store_locale(&new_locale);
+    new_locale
+}
+
+/// Get the available locales.
+pub fn available_locales() -> Vec<&'static str> {
+    data()
+        .0
+        .iter()
+        .flat_map(|(_, v)| v.0.keys().map(AsRef::as_ref))
+        .unique()
+        .sorted_unstable()
+        .collect::<Vec<_>>()
+}
+
+/// A macro to translate an item to the current locale.
+#[macro_export]
+macro_rules! t {
+    ($key:expr) => {
+        std::borrow::Cow::Borrowed($crate::locale::__translate($key))
+    };
+    ($key:expr, $($kt:ident = $kv:expr),+) => {
+        {
+            let string = t!($key);
+            $(
+                let string = string.replace(concat!("%{", stringify!($kt), "}"), &$kv.to_string());
+            )+
+            string
+        }
+    };
+    ($key:expr, $($kt:literal => $kv:expr),+) => {
+        {
+            let string = t!($key);
+            $(
+                let string = string.replace(concat!("%{", $kt, "}"), &$kv.to_string());
+            )+
+            string
+        }
+    };
+}
+
+/// Translate an item to the current locale.
+///
+/// This function is public as it is used by the `t!` macro, however is not considered part of the
+/// public interface.
+#[doc(hidden)]
+pub fn __translate(item: &str) -> &str {
+    let locale = CURRENT_LOCALE.with(Clone::clone);
+    let binding = locale.borrow();
+    translate_locale(item, binding.as_str())
+}
+
+/// Translate an item to a specific locale.
+///
+/// If the item does not exists, the key is returned. Otherwise, if item does not contain the
+/// locale, the fallback locale is used. If the fallback locale does not exist, the key is
+/// returned.
+fn translate_locale<'a>(item: &'a str, locale: &str) -> &'a str {
+    if let Some(key) = data().0.get(item) {
+        if let Some(value) = key.0.get(locale) {
+            value
+        } else if let Some(value) = key.0.get(&split_locale(locale)) {
+            value
+        } else if let Some(value) = key.0.get(FALLBACK_LOCALE) {
+            value
+        } else {
+            item
+        }
     } else {
-        set_locale_inner(FALLBACK_LOCALE);
+        item
     }
 }
 
-/// Get the current locale.
-pub fn locale() -> String {
-    rust_i18n::locale().to_string()
+/// Get the locale data.
+fn data() -> &'static Data {
+    static DATA: OnceLock<Data> = OnceLock::new();
+    DATA.get_or_init(|| {
+        toml::from_str(include_str!("../locales.toml")).expect("Failed to parse locales.toml")
+    })
 }
 
-/// Get all available locales.
-pub fn available_locales() -> Vec<&'static str> {
-    rust_i18n::available_locales!()
-}
+/// This is a map of a item name (i.e. `title_hops`, `awaiting_data`, etc.) to the locale `Item`.
+#[derive(Debug, Deserialize)]
+struct Data(HashMap<String, Item>);
 
-fn set_locale_inner(locale: &str) {
-    let all_locales = rust_i18n::available_locales!();
-    if all_locales.contains(&locale) {
-        rust_i18n::set_locale(locale);
+/// This is a map of locale keys (i.e. `en`, `zh`, etc.) to the translated value.
+#[derive(Debug, Deserialize)]
+struct Item(HashMap<String, String>);
+
+/// calculate the locale to use.
+fn calculate_locale(cfg_locale: Option<&str>, sys_locale: Option<&str>) -> String {
+    let target_locale = if let Some(locale) = cfg_locale {
+        locale
+    } else if let Some(locale) = sys_locale {
+        locale
+    } else {
+        FALLBACK_LOCALE
+    };
+    let all_locales = available_locales();
+    if all_locales.contains(&target_locale) {
+        String::from(target_locale)
     } else {
-        let language = split_locale(locale);
+        let language = split_locale(target_locale);
         if all_locales.contains(&language.as_str()) {
-            rust_i18n::set_locale(&language);
+            language
         } else {
-            rust_i18n::set_locale(FALLBACK_LOCALE);
+            String::from(FALLBACK_LOCALE)
         }
     }
 }
 
+/// Split a locale into language and region parts and return the language part.
 fn split_locale(locale: &str) -> String {
     let mut parts = locale.split(['-', '_']);
     parts
@@ -48,33 +134,77 @@ fn split_locale(locale: &str) -> String {
         .to_string()
 }
 
-// A macro for translating a text string.
-#[macro_export]
-macro_rules! t {
-    ($($all:tt)*) => {
-        rust_i18n::t!($($all)*)
-    }
+thread_local! {
+    static CURRENT_LOCALE: RefCell<String> = RefCell::new(String::from(FALLBACK_LOCALE));
+}
+
+fn store_locale(new_locale: &str) {
+    CURRENT_LOCALE.with(|locale| *locale.borrow_mut() = String::from(new_locale));
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
+    use test_case::test_case;
+
+    #[test_case("en-US", "en"; "dash")]
+    #[test_case("en_US", "en"; "underscore")]
+    #[test_case("en", "en"; "no_region")]
+    #[test_case("zh-", "zh"; "invalid_dash")]
+    #[test_case("zh_", "zh"; "invalid_underscore")]
+    #[test_case("en?", "en?"; "invalid_accepted")]
+    fn test_split_locale(locale: &str, expected: &str) {
+        assert_eq!(split_locale(locale), expected);
+    }
+
+    #[test_case(None, None, "en"; "no_locale")]
+    #[test_case(Some("en"), None, "en"; "cfg_locale")]
+    #[test_case(None, Some("en"), "en"; "sys_locale")]
+    #[test_case(Some("en"), Some("en"), "en"; "both_locales")]
+    #[test_case(Some("en"), Some("zh"), "en"; "both_locales_mismatch")]
+    #[test_case(Some("zh"), Some("en"), "zh"; "both_locales_mismatch_reverse")]
+    #[test_case(Some("en-US"), None, "en"; "cfg_locale_dash")]
+    #[test_case(None, Some("en-US"), "en"; "sys_locale_dash")]
+    #[test_case(Some("en-US"), Some("en-US"), "en"; "both_locales_dash")]
+    #[test_case(Some("en-US"), Some("zh-CN"), "en"; "both_locales_mismatch_dash")]
+    #[test_case(Some("zh-CN"), Some("en-US"), "zh"; "both_locales_mismatch_reverse_dash")]
+    #[test_case(Some("en_US"), None, "en"; "cfg_locale_underscore")]
+    #[test_case(None, Some("en_US"), "en"; "sys_locale_underscore")]
+    #[test_case(Some("xx"), None, "en"; "cfg_locale_unknown")]
+    #[test_case(None, Some("xx"), "en"; "sys_locale_unknown")]
+    #[test_case(Some("xx"), Some("xx"), "en"; "both_locales_unknown")]
+    #[test_case(Some("en-"), None, "en"; "cfg_locale_invalid_dash")]
+    #[test_case(Some("en_"), None, "en"; "cfg_locale_invalid_underscore")]
+    #[test_case(Some("en?"), None, "en"; "cfg_locale_invalid_accepted")]
+    fn test_set_locale(cfg_locale: Option<&str>, sys_locale: Option<&str>, expected: &str) {
+        assert_eq!(calculate_locale(cfg_locale, sys_locale), expected);
+    }
+
+    #[test]
+    fn test_available_languages() {
+        assert_eq!(
+            available_locales(),
+            vec!["de", "en", "es", "fr", "it", "pt", "ru", "sv", "tr", "zh"]
+        );
+    }
 
     #[test]
-    fn test_split_locale_dash() {
-        let language = split_locale("en-US");
-        assert_eq!(language, "en");
+    fn test_data_deserialize() {
+        assert!(!data().0.is_empty());
     }
 
     #[test]
-    fn test_split_locale_underscore() {
-        let language = split_locale("en_US");
-        assert_eq!(language, "en");
+    fn test_translate() {
+        assert_eq!(translate_locale("title_hops", "en"), "Hops");
+        assert_eq!(translate_locale("title_hops", "zh"), "跳");
+        assert_eq!(translate_locale("unknown_item", "en"), "unknown_item");
+        assert_eq!(translate_locale("unknown_locale", "xx"), "unknown_locale");
     }
 
     #[test]
-    fn test_split_locale_no_region() {
-        let language = split_locale("en");
-        assert_eq!(language, "en");
+    fn test_translate_macro() {
+        assert_eq!(t!("title_hops"), "Hops");
+        assert_eq!(t!("awaiting_data"), "Awaiting data...");
+        assert_eq!(t!("unknown_item"), "unknown_item");
     }
 }
diff --git a/crates/trippy-tui/src/print.rs b/crates/trippy-tui/src/print.rs
index ce14aa2c..d6bfed2d 100644
--- a/crates/trippy-tui/src/print.rs
+++ b/crates/trippy-tui/src/print.rs
@@ -2,7 +2,6 @@ use crate::config::{Args, TuiCommandItem, TuiThemeItem};
 use crate::locale::available_locales;
 use clap::CommandFactory;
 use clap_complete::Shell;
-use itertools::Itertools;
 use std::process;
 use strum::VariantNames;
 
@@ -32,7 +31,7 @@ pub fn print_man_page() -> anyhow::Result<()> {
 }
 
 pub fn print_locales() {
-    println!("TUI locales: {}", available_locales().iter().join(", "));
+    println!("TUI locales: {}", available_locales().join(", "));
     process::exit(0);
 }
 


Test patch: