From 80c11cc4298e9ec5802ffce98204e0573f5f99ec Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:05:02 +0200 Subject: [PATCH 01/20] feat(theme): add theme module skeleton with Mocha builtin Adds Base16Scheme + Theme structs, Theme::from_scheme slot mapping, and MOCHA constant. New deps: dirs, serde, toml; tempfile dev-dep. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 6 ++ src/main.rs | 1 + src/theme.rs | 100 ++++++++++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 src/theme.rs diff --git a/Cargo.lock b/Cargo.lock index 3d10606..02ff855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -617,10 +617,14 @@ dependencies = [ "catppuccin", "clap", "crossterm", + "dirs", "object_store", "polars", "ratatui", + "serde", + "tempfile", "tokio", + "toml", ] [[package]] @@ -676,6 +680,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -773,6 +798,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1464,6 +1495,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -1752,6 +1792,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -2798,6 +2844,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -3087,6 +3144,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3396,6 +3462,19 @@ dependencies = [ "windows", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3594,6 +3673,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -4194,6 +4314,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4230,6 +4359,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4263,6 +4407,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4275,6 +4425,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4287,6 +4443,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4311,6 +4473,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4323,6 +4491,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4335,6 +4509,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4347,6 +4527,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4359,6 +4545,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index c67315f..d851829 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,9 +20,15 @@ ratatui = "0.30.0" polars = { version = "0.46", features = ["csv", "parquet", "lazy", "strings", "regex", "json", "temporal", "dtype-date", "dtype-datetime", "dtype-decimal"] } catppuccin = "2" clap = { version = "4", features = ["derive"] } +dirs = "5" +serde = { version = "1", features = ["derive"] } +toml = "0.8" object_store = { version = "0.11", optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true } +[dev-dependencies] +tempfile = "3" + # The profile that 'dist' will build with [profile.dist] inherits = "release" diff --git a/src/main.rs b/src/main.rs index 8064141..2e56aec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app; mod browser; mod config; mod events; +mod theme; mod ui; use app::App; diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..71e1932 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,100 @@ +use ratatui::style::Color; + +pub struct Base16Scheme { + pub name: &'static str, + pub display_name: &'static str, + pub base: [[u8; 3]; 16], +} + +pub struct Theme { + pub name: &'static str, + pub display_name: &'static str, + pub bg: Color, + pub bg_alt: Color, + pub bg_sel: Color, + pub border_idle: Color, + pub fg_muted: Color, + pub fg_dim: Color, + pub fg: Color, + pub accent: Color, + pub error: Color, + pub warn: Color, + pub success: Color, + pub info: Color, + pub series: [Color; 6], +} + +const fn rgb(c: [u8; 3]) -> Color { + Color::Rgb(c[0], c[1], c[2]) +} + +impl Theme { + pub const fn from_scheme(s: &Base16Scheme) -> Self { + Self { + name: s.name, + display_name: s.display_name, + bg: rgb(s.base[0x00]), + bg_alt: rgb(s.base[0x01]), + bg_sel: rgb(s.base[0x02]), + border_idle: rgb(s.base[0x03]), + fg_muted: rgb(s.base[0x03]), + fg_dim: rgb(s.base[0x04]), + fg: rgb(s.base[0x05]), + accent: rgb(s.base[0x0D]), + error: rgb(s.base[0x08]), + warn: rgb(s.base[0x0A]), + success: rgb(s.base[0x0B]), + info: rgb(s.base[0x0C]), + series: [ + rgb(s.base[0x0D]), + rgb(s.base[0x0B]), + rgb(s.base[0x09]), + rgb(s.base[0x0E]), + rgb(s.base[0x0C]), + rgb(s.base[0x0A]), + ], + } + } +} + +pub static MOCHA: Base16Scheme = Base16Scheme { + name: "mocha", + display_name: "Catppuccin Mocha", + base: [ + [0x1e, 0x1e, 0x2e], + [0x18, 0x18, 0x25], + [0x31, 0x32, 0x44], + [0x45, 0x47, 0x5a], + [0x58, 0x5b, 0x70], + [0xcd, 0xd6, 0xf4], + [0xf5, 0xe0, 0xdc], + [0xb4, 0xbe, 0xfe], + [0xf3, 0x8b, 0xa8], + [0xfa, 0xb3, 0x87], + [0xf9, 0xe2, 0xaf], + [0xa6, 0xe3, 0xa1], + [0x94, 0xe2, 0xd5], + [0x89, 0xb4, 0xfa], + [0xcb, 0xa6, 0xf7], + [0xf2, 0xcd, 0xcd], + ], +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mocha_loads_with_all_slots_populated() { + let t = Theme::from_scheme(&MOCHA); + assert_eq!(t.name, "mocha"); + assert_eq!(t.display_name, "Catppuccin Mocha"); + assert_eq!(t.bg, Color::Rgb(0x1e, 0x1e, 0x2e)); + assert_eq!(t.fg, Color::Rgb(0xcd, 0xd6, 0xf4)); + assert_eq!(t.accent, Color::Rgb(0x89, 0xb4, 0xfa)); + assert_eq!(t.error, Color::Rgb(0xf3, 0x8b, 0xa8)); + for s in t.series.iter() { + assert!(matches!(s, Color::Rgb(_, _, _))); + } + } +} From 5aaeabeb72f2a43b96005b635ce81370c3df0736 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:12:55 +0200 Subject: [PATCH 02/20] chore: gitignore docs/superpowers (local-only specs and plans) Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0e5fb52..477837e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target test_data.csv test_data.parquet +docs/superpowers/ From ee17c184b68c276351ae9ee18eebe72052067e73 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:13:08 +0200 Subject: [PATCH 03/20] feat(theme): add 8 more Base16 schemes (Catppuccin variants, Gruvbox, Nord, Dracula, Solarized, Tokyo Night) Co-Authored-By: Claude Opus 4.7 --- src/theme.rs | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/src/theme.rs b/src/theme.rs index 71e1932..c75c574 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -80,6 +80,190 @@ pub static MOCHA: Base16Scheme = Base16Scheme { ], }; +pub static LATTE: Base16Scheme = Base16Scheme { + name: "latte", + display_name: "Catppuccin Latte", + base: [ + [0xef, 0xf1, 0xf5], + [0xe6, 0xe9, 0xef], + [0xcc, 0xd0, 0xda], + [0xbc, 0xc0, 0xcc], + [0xac, 0xb0, 0xbe], + [0x4c, 0x4f, 0x69], + [0xdc, 0x8a, 0x78], + [0x7c, 0x7f, 0x93], + [0xd2, 0x0f, 0x39], + [0xfe, 0x64, 0x0b], + [0xdf, 0x8e, 0x1d], + [0x40, 0xa0, 0x2b], + [0x17, 0x91, 0x99], + [0x1e, 0x66, 0xf5], + [0x88, 0x39, 0xef], + [0xdd, 0x76, 0x78], + ], +}; + +pub static FRAPPE: Base16Scheme = Base16Scheme { + name: "frappe", + display_name: "Catppuccin Frappé", + base: [ + [0x30, 0x33, 0x46], + [0x29, 0x2c, 0x3c], + [0x41, 0x45, 0x59], + [0x51, 0x57, 0x6d], + [0x62, 0x68, 0x80], + [0xc6, 0xd0, 0xf5], + [0xf2, 0xd5, 0xcf], + [0xba, 0xbb, 0xf1], + [0xe7, 0x82, 0x84], + [0xef, 0x9f, 0x76], + [0xe5, 0xc8, 0x90], + [0xa6, 0xd1, 0x89], + [0x81, 0xc8, 0xbe], + [0x8c, 0xaa, 0xee], + [0xca, 0x9e, 0xe6], + [0xee, 0xbe, 0xbe], + ], +}; + +pub static MACCHIATO: Base16Scheme = Base16Scheme { + name: "macchiato", + display_name: "Catppuccin Macchiato", + base: [ + [0x24, 0x27, 0x3a], + [0x1e, 0x20, 0x30], + [0x36, 0x3a, 0x4f], + [0x49, 0x4d, 0x64], + [0x5b, 0x60, 0x78], + [0xca, 0xd3, 0xf5], + [0xf4, 0xdb, 0xd6], + [0xb7, 0xbd, 0xf8], + [0xed, 0x87, 0x96], + [0xf5, 0xa9, 0x7f], + [0xee, 0xd4, 0x9f], + [0xa6, 0xda, 0x95], + [0x8b, 0xd5, 0xca], + [0x8a, 0xad, 0xf4], + [0xc6, 0xa0, 0xf6], + [0xf0, 0xc6, 0xc6], + ], +}; + +pub static GRUVBOX_DARK: Base16Scheme = Base16Scheme { + name: "gruvbox-dark", + display_name: "Gruvbox Dark", + base: [ + [0x28, 0x28, 0x28], + [0x3c, 0x38, 0x36], + [0x50, 0x49, 0x45], + [0x66, 0x5c, 0x54], + [0xbd, 0xae, 0x93], + [0xd5, 0xc4, 0xa1], + [0xeb, 0xdb, 0xb2], + [0xfb, 0xf1, 0xc7], + [0xfb, 0x49, 0x34], + [0xfe, 0x80, 0x19], + [0xfa, 0xbd, 0x2f], + [0xb8, 0xbb, 0x26], + [0x8e, 0xc0, 0x7c], + [0x83, 0xa5, 0x98], + [0xd3, 0x86, 0x9b], + [0xd6, 0x5d, 0x0e], + ], +}; + +pub static NORD: Base16Scheme = Base16Scheme { + name: "nord", + display_name: "Nord", + base: [ + [0x2e, 0x34, 0x40], + [0x3b, 0x42, 0x52], + [0x43, 0x4c, 0x5e], + [0x4c, 0x56, 0x6a], + [0xd8, 0xde, 0xe9], + [0xe5, 0xe9, 0xf0], + [0xec, 0xef, 0xf4], + [0x8f, 0xbc, 0xbb], + [0xbf, 0x61, 0x6a], + [0xd0, 0x87, 0x70], + [0xeb, 0xcb, 0x8b], + [0xa3, 0xbe, 0x8c], + [0x88, 0xc0, 0xd0], + [0x81, 0xa1, 0xc1], + [0xb4, 0x8e, 0xad], + [0x5e, 0x81, 0xac], + ], +}; + +pub static DRACULA: Base16Scheme = Base16Scheme { + name: "dracula", + display_name: "Dracula", + base: [ + [0x28, 0x29, 0x36], + [0x3a, 0x3c, 0x4e], + [0x4d, 0x4f, 0x68], + [0x62, 0x64, 0x83], + [0x62, 0x64, 0x83], + [0xe9, 0xe9, 0xf4], + [0xf1, 0xf2, 0xf8], + [0xf7, 0xf7, 0xfb], + [0xea, 0x51, 0xb2], + [0xb4, 0x5b, 0xcf], + [0x00, 0xf7, 0x69], + [0xeb, 0xff, 0x87], + [0xa1, 0xef, 0xe4], + [0x62, 0xd6, 0xe8], + [0xb4, 0x5b, 0xcf], + [0x00, 0xf7, 0x69], + ], +}; + +pub static SOLARIZED_DARK: Base16Scheme = Base16Scheme { + name: "solarized-dark", + display_name: "Solarized Dark", + base: [ + [0x00, 0x2b, 0x36], + [0x07, 0x36, 0x42], + [0x58, 0x6e, 0x75], + [0x65, 0x7b, 0x83], + [0x83, 0x94, 0x96], + [0x93, 0xa1, 0xa1], + [0xee, 0xe8, 0xd5], + [0xfd, 0xf6, 0xe3], + [0xdc, 0x32, 0x2f], + [0xcb, 0x4b, 0x16], + [0xb5, 0x89, 0x00], + [0x85, 0x99, 0x00], + [0x2a, 0xa1, 0x98], + [0x26, 0x8b, 0xd2], + [0x6c, 0x71, 0xc4], + [0xd3, 0x36, 0x82], + ], +}; + +pub static TOKYO_NIGHT: Base16Scheme = Base16Scheme { + name: "tokyo-night", + display_name: "Tokyo Night", + base: [ + [0x1a, 0x1b, 0x26], + [0x16, 0x16, 0x1e], + [0x2f, 0x33, 0x49], + [0x44, 0x4b, 0x6a], + [0x78, 0x7c, 0x99], + [0xa9, 0xb1, 0xd6], + [0xcb, 0xcc, 0xd1], + [0xd5, 0xd6, 0xdb], + [0xc0, 0xca, 0xf5], + [0xa9, 0xb1, 0xd6], + [0x0d, 0xb9, 0xd7], + [0x9e, 0xce, 0x6a], + [0xb4, 0xf9, 0xf8], + [0x2a, 0xc3, 0xde], + [0xbb, 0x9a, 0xf7], + [0xf7, 0x76, 0x8e], + ], +}; + #[cfg(test)] mod tests { use super::*; @@ -97,4 +281,36 @@ mod tests { assert!(matches!(s, Color::Rgb(_, _, _))); } } + + #[test] + fn all_nine_builtins_load() { + let names = [ + "mocha", + "latte", + "frappe", + "macchiato", + "gruvbox-dark", + "nord", + "dracula", + "solarized-dark", + "tokyo-night", + ]; + for &n in &names { + let scheme = match n { + "mocha" => &MOCHA, + "latte" => &LATTE, + "frappe" => &FRAPPE, + "macchiato" => &MACCHIATO, + "gruvbox-dark" => &GRUVBOX_DARK, + "nord" => &NORD, + "dracula" => &DRACULA, + "solarized-dark" => &SOLARIZED_DARK, + "tokyo-night" => &TOKYO_NIGHT, + _ => unreachable!(), + }; + let t = Theme::from_scheme(scheme); + assert_eq!(t.name, n, "name field for {}", n); + assert_ne!(t.bg, t.fg, "bg == fg for {}", n); + } + } } From d69691e6bea9edd61fec2958769596549c961dad Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:14:25 +0200 Subject: [PATCH 04/20] feat(theme): add theme_by_name, list_themes, theme_names_csv helpers Co-Authored-By: Claude Opus 4.7 --- src/theme.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/theme.rs b/src/theme.rs index c75c574..2df7bed 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,5 @@ use ratatui::style::Color; +use std::sync::OnceLock; pub struct Base16Scheme { pub name: &'static str, @@ -264,6 +265,44 @@ pub static TOKYO_NIGHT: Base16Scheme = Base16Scheme { ], }; +fn all_themes_init() -> Vec { + [ + &MOCHA, + &LATTE, + &FRAPPE, + &MACCHIATO, + &GRUVBOX_DARK, + &NORD, + &DRACULA, + &SOLARIZED_DARK, + &TOKYO_NIGHT, + ] + .iter() + .map(|s| Theme::from_scheme(s)) + .collect() +} + +pub fn list_themes() -> &'static [Theme] { + static THEMES: OnceLock> = OnceLock::new(); + THEMES.get_or_init(all_themes_init) +} + +pub fn theme_by_name(name: &str) -> Option<&'static Theme> { + list_themes().iter().find(|t| t.name == name) +} + +pub fn default_theme() -> &'static Theme { + theme_by_name("mocha").expect("mocha is always present") +} + +pub fn theme_names_csv() -> String { + list_themes() + .iter() + .map(|t| t.name) + .collect::>() + .join(", ") +} + #[cfg(test)] mod tests { use super::*; @@ -313,4 +352,41 @@ mod tests { assert_ne!(t.bg, t.fg, "bg == fg for {}", n); } } + + #[test] + fn theme_by_name_returns_known() { + assert_eq!(theme_by_name("mocha").map(|t| t.name), Some("mocha")); + assert_eq!(theme_by_name("nord").map(|t| t.name), Some("nord")); + assert_eq!( + theme_by_name("gruvbox-dark").map(|t| t.name), + Some("gruvbox-dark") + ); + } + + #[test] + fn theme_by_name_returns_none_for_unknown() { + assert!(theme_by_name("does-not-exist").is_none()); + assert!(theme_by_name("").is_none()); + } + + #[test] + fn list_themes_returns_all_nine() { + let names: Vec<&str> = list_themes().iter().map(|t| t.name).collect(); + assert_eq!(names.len(), 9); + assert!(names.contains(&"mocha")); + assert!(names.contains(&"tokyo-night")); + } + + #[test] + fn theme_names_alphabetical_helper_returns_csv() { + let csv = theme_names_csv(); + assert!(csv.contains("mocha")); + assert!(csv.contains("nord")); + assert!(csv.contains(", ")); + } + + #[test] + fn default_theme_is_mocha() { + assert_eq!(default_theme().name, "mocha"); + } } From 0effe525c5c8b0d68c1f179a6b82a4c543810cd6 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:15:10 +0200 Subject: [PATCH 05/20] feat(theme): add state.toml read/write at explicit path Co-Authored-By: Claude Opus 4.7 --- src/theme.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/theme.rs b/src/theme.rs index 2df7bed..b841fa7 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,5 @@ use ratatui::style::Color; +use std::path::{Path, PathBuf}; use std::sync::OnceLock; pub struct Base16Scheme { @@ -303,6 +304,33 @@ pub fn theme_names_csv() -> String { .join(", ") } +#[derive(serde::Deserialize, serde::Serialize)] +struct StateFile { + theme: String, +} + +pub fn state_path() -> Option { + dirs::config_dir().map(|p| p.join("datasight").join("state.toml")) +} + +pub fn read_state_theme_at(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + let parsed: StateFile = toml::from_str(&contents).ok()?; + Some(parsed.theme) +} + +pub fn write_state_theme_at(path: &Path, name: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let state = StateFile { + theme: name.to_string(), + }; + let s = toml::to_string(&state) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + std::fs::write(path, s) +} + #[cfg(test)] mod tests { use super::*; @@ -389,4 +417,36 @@ mod tests { fn default_theme_is_mocha() { assert_eq!(default_theme().name, "mocha"); } + + #[test] + fn read_state_returns_none_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.toml"); + assert!(read_state_theme_at(&path).is_none()); + } + + #[test] + fn read_state_returns_none_for_malformed_toml() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.toml"); + std::fs::write(&path, "this is not valid toml ::: !!!").unwrap(); + assert!(read_state_theme_at(&path).is_none()); + } + + #[test] + fn write_then_read_state_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.toml"); + write_state_theme_at(&path, "nord").unwrap(); + assert_eq!(read_state_theme_at(&path).as_deref(), Some("nord")); + } + + #[test] + fn write_state_creates_parent_directory() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("dir").join("state.toml"); + write_state_theme_at(&path, "dracula").unwrap(); + assert!(path.exists()); + assert_eq!(read_state_theme_at(&path).as_deref(), Some("dracula")); + } } From 3614423fd73f07934ddceaad9dcff6fc3bce80e4 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:15:52 +0200 Subject: [PATCH 06/20] feat(theme): add resolve_theme with CLI > env > state > default precedence Co-Authored-By: Claude Opus 4.7 --- src/theme.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/theme.rs b/src/theme.rs index b841fa7..3bf0a55 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -8,6 +8,7 @@ pub struct Base16Scheme { pub base: [[u8; 3]; 16], } +#[derive(Debug)] pub struct Theme { pub name: &'static str, pub display_name: &'static str, @@ -331,6 +332,27 @@ pub fn write_state_theme_at(path: &Path, name: &str) -> std::io::Result<()> { std::fs::write(path, s) } +pub fn resolve_theme( + cli: Option<&str>, + env: Option, + state: Option, +) -> Result<&'static Theme, String> { + if let Some(name) = cli { + return theme_by_name(name) + .ok_or_else(|| format!("unknown theme: {} (try: {})", name, theme_names_csv())); + } + if let Some(name) = env { + return theme_by_name(&name) + .ok_or_else(|| format!("unknown theme: {} (try: {})", name, theme_names_csv())); + } + if let Some(name) = state { + if let Some(t) = theme_by_name(&name) { + return Ok(t); + } + } + Ok(default_theme()) +} + #[cfg(test)] mod tests { use super::*; @@ -449,4 +471,58 @@ mod tests { assert!(path.exists()); assert_eq!(read_state_theme_at(&path).as_deref(), Some("dracula")); } + + #[test] + fn resolve_uses_cli_arg_when_provided() { + let result = resolve_theme(Some("nord"), Some("dracula".into()), Some("latte".into())); + assert!(result.is_ok()); + assert_eq!(result.unwrap().name, "nord"); + } + + #[test] + fn resolve_falls_back_to_env_when_no_cli() { + let result = resolve_theme(None, Some("dracula".into()), Some("latte".into())); + assert!(result.is_ok()); + assert_eq!(result.unwrap().name, "dracula"); + } + + #[test] + fn resolve_falls_back_to_state_when_no_cli_or_env() { + let result = resolve_theme(None, None, Some("latte".into())); + assert!(result.is_ok()); + assert_eq!(result.unwrap().name, "latte"); + } + + #[test] + fn resolve_falls_back_to_default_when_nothing_provided() { + let result = resolve_theme(None, None, None); + assert!(result.is_ok()); + assert_eq!(result.unwrap().name, "mocha"); + } + + #[test] + fn resolve_unknown_cli_returns_error() { + let result = resolve_theme(Some("nonsense"), None, None); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("unknown theme: nonsense")); + assert!( + err.contains("mocha"), + "error should list valid themes, got: {}", + err + ); + } + + #[test] + fn resolve_unknown_env_returns_error() { + let result = resolve_theme(None, Some("nonsense".into()), None); + assert!(result.is_err()); + } + + #[test] + fn resolve_unknown_state_falls_back_to_default() { + let result = resolve_theme(None, None, Some("stale-name".into())); + assert!(result.is_ok()); + assert_eq!(result.unwrap().name, "mocha"); + } } From 15621ca5353b95b0161dd1de84be43a1e6d8fc1f Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:19:36 +0200 Subject: [PATCH 07/20] feat(theme): add --theme CLI flag and resolve at startup Co-Authored-By: Claude Opus 4.7 --- src/main.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main.rs b/src/main.rs index 2e56aec..57f9f37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,9 @@ struct Cli { /// Defaults to '\t' for .tsv files and ',' for everything else. #[arg(short = 'd', long, value_name = "CHAR")] delimiter: Option, + /// Color theme: mocha (default), latte, frappe, macchiato, gruvbox-dark, nord, dracula, solarized-dark, tokyo-night + #[arg(long, global = true)] + theme: Option, #[command(subcommand)] command: Option, } @@ -266,6 +269,19 @@ fn main() -> Result<(), Box> { use std::io::IsTerminal; let cli = Cli::parse(); + let _theme: &'static theme::Theme = { + let cli_theme = cli.theme.as_deref(); + let env_theme = std::env::var("DATASIGHT_THEME").ok(); + let state_theme = theme::state_path().and_then(|p| theme::read_state_theme_at(&p)); + match theme::resolve_theme(cli_theme, env_theme, state_theme) { + Ok(t) => t, + Err(msg) => { + eprintln!("{}", msg); + std::process::exit(2); + } + } + }; + if let Some(Commands::Browse { path }) = cli.command { let root = path.unwrap_or_else(|| ".".to_string()); let (backend, resolved) = browser::build_backend(&root).unwrap_or_else(|err| { From d7791a9a76662dfb9acb2440e50e0f06fc7a5390 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:49:34 +0200 Subject: [PATCH 08/20] feat(theme): wire Theme through App and refactor ui.rs to read theme slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `theme: &'static Theme` to App, plumb the resolved theme from main.rs through App::new, and replace ~140 hardcoded `c(m.)` Catppuccin Mocha references in src/ui.rs and src/browser/ui.rs with semantic Theme slots (`theme.bg`, `theme.accent`, `theme.error`, `theme.series[N]`, …) per the Base16 mapping table. BrowserApp uses default_theme() as a Task 8 placeholder. Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 5 +- src/app_tests.rs | 54 +++---- src/browser/events.rs | 2 +- src/browser/ui.rs | 57 ++++--- src/main.rs | 4 +- src/theme.rs | 7 +- src/ui.rs | 344 ++++++++++++++++++++---------------------- 7 files changed, 231 insertions(+), 242 deletions(-) diff --git a/src/app.rs b/src/app.rs index 43c6296..7334b58 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ //! mode to choose what to render. use crate::config; +use crate::theme::Theme; use polars::prelude::*; use ratatui::widgets::TableState; use std::collections::{HashMap, HashSet}; @@ -162,6 +163,7 @@ pub struct App { pub unique_values: UniqueValuesState, pub columns_view: ColumnsViewState, pub viewport: ViewportState, + pub theme: &'static Theme, } /// Strips a leading comparison operator from `query`. @@ -317,7 +319,7 @@ fn build_committed_filter_expr(col_name: &str, query: &str) -> Expr { } impl App { - pub fn new(df: DataFrame, file_path: String) -> App { + pub fn new(df: DataFrame, file_path: String, theme: &'static Theme) -> App { let headers: Vec = df .get_column_names() .iter() @@ -346,6 +348,7 @@ impl App { unique_values: UniqueValuesState::default(), columns_view: ColumnsViewState::default(), viewport: ViewportState::default(), + theme, }; if !app.df.is_empty() { app.state.select(Some(0)); diff --git a/src/app_tests.rs b/src/app_tests.rs index 67fc617..145b069 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -12,7 +12,7 @@ mod tests { "age" => [30i64, 25, 35], } .unwrap(); - App::new(df, "test.csv".to_string()) + App::new(df, "test.csv".to_string(), crate::theme::default_theme()) } fn get_str(app: &App, col: &str, row: usize) -> String { @@ -105,7 +105,7 @@ mod tests { #[test] fn test_empty_dataframe_new() { let df = DataFrame::empty(); - let app = App::new(df, "empty.csv".to_string()); + let app = App::new(df, "empty.csv".to_string(), crate::theme::default_theme()); assert!(app.state.selected().is_none()); assert!(app.state.selected_column().is_none()); assert!(app.headers.is_empty()); @@ -118,7 +118,7 @@ mod tests { "age" => [30i64, 25], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); // Filter to zero rows then search — must not panic app.filter.filters = vec![("name".to_string(), "zzznomatch".to_string())]; app.update_filter(); @@ -133,7 +133,7 @@ mod tests { "val" => [1i64, 2, 3], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.filter.filters = vec![("val".to_string(), "zzznomatch".to_string())]; app.update_filter(); // Should return default stats without panicking @@ -248,7 +248,7 @@ mod tests { "sal" => [200i64, 150, 100], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); // Primary sort: dept ascending app.state.select_column(Some(0)); @@ -298,7 +298,7 @@ mod tests { "sal" => [200i64, 150, 100, 300], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); // Sort by dept asc (primary), sal asc (secondary) app.state.select_column(Some(0)); @@ -323,7 +323,7 @@ mod tests { "sal" => [100i64, 200, 150], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.state.select_column(Some(1)); app.sort_by_column(); @@ -348,7 +348,7 @@ mod columns_view_tests { "val" => [1i64, 2, 3], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.build_columns_profile(); let p = &app.columns_view.profile[0]; assert_eq!(p.name, "val"); @@ -364,7 +364,7 @@ mod columns_view_tests { "name" => ["a", "b", "c"], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.build_columns_profile(); let p = &app.columns_view.profile[0]; assert!(p.mean.is_none()); @@ -381,7 +381,7 @@ mod groupby_tests { "sal" => [100i64, 200, 150], } .unwrap(); - App::new(df, "test.csv".to_string()) + App::new(df, "test.csv".to_string(), crate::theme::default_theme()) } #[test] @@ -462,7 +462,7 @@ mod groupby_tests { "sal" => [100i64, 200, 300, 150, 250], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); // Filter to North only, then groupby dept with sum(sal) app.filter.filters = vec![("region".to_string(), "= N".to_string())]; @@ -534,7 +534,7 @@ mod groupby_tests { "sal" => [100i64, 200, 150], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.filter.filters = vec![("region".to_string(), "= N".to_string())]; app.update_filter(); @@ -569,7 +569,7 @@ mod filter_expr_tests { "age" => [18i64, 25, 30], } .unwrap(); - App::new(df, "test.csv".to_string()) + App::new(df, "test.csv".to_string(), crate::theme::default_theme()) } fn apply(app: &mut App, col_idx: usize, query: &str) -> usize { @@ -636,7 +636,7 @@ mod plot_tests { "y" => [10i32, 20i32, 30i32], } .unwrap(); - let app = App::new(df, "test.csv".to_string()); + let app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let (data, x_is_categorical) = crate::ui::extract_plot_data_pub(&app, 0, 1); assert!(!data.is_empty(), "both numeric: data should not be empty"); assert_eq!(data.len(), 3); @@ -651,7 +651,7 @@ mod plot_tests { "qty" => [10i32, 20i32, 30i32], } .unwrap(); - let app = App::new(df, "test.csv".to_string()); + let app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let (data, x_is_categorical) = crate::ui::extract_plot_data_pub(&app, 0, 1); assert!(!data.is_empty(), "string x: should use row index"); assert_eq!(data[0], (0.0, 10.0)); @@ -669,7 +669,7 @@ mod plot_tests { #[test] fn test_plot_pick_y_toggle_adds_column() { let df = df! { "a" => [1i32], "b" => [2i32] }.unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.plot.y_cols.clear(); toggle_y_col(&mut app, 0); assert_eq!(app.plot.y_cols, vec![0]); @@ -678,7 +678,7 @@ mod plot_tests { #[test] fn test_plot_pick_y_toggle_removes_column() { let df = df! { "a" => [1i32], "b" => [2i32] }.unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.plot.y_cols = vec![0]; toggle_y_col(&mut app, 0); assert!(app.plot.y_cols.is_empty()); @@ -687,7 +687,7 @@ mod plot_tests { #[test] fn test_plot_pick_y_toggle_twice_restores_state() { let df = df! { "a" => [1i32], "b" => [2i32] }.unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.plot.y_cols.clear(); toggle_y_col(&mut app, 1); toggle_y_col(&mut app, 1); @@ -700,7 +700,7 @@ mod plot_tests { #[test] fn test_plot_pick_y_toggle_multiple_columns() { let df = df! { "a" => [1i32], "b" => [2i32], "c" => [3i32] }.unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.plot.y_cols.clear(); toggle_y_col(&mut app, 0); toggle_y_col(&mut app, 2); @@ -885,7 +885,7 @@ mod chained_filter_tests { "sal" => [100i64, 200, 150], } .unwrap(); - App::new(df, "test.csv".to_string()) + App::new(df, "test.csv".to_string(), crate::theme::default_theme()) } #[test] @@ -955,7 +955,7 @@ mod stats_tests { "val" => [10i64, 20, 30], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let stats = app.compute_stats(0); assert_eq!(stats.count, 3); assert_eq!(stats.min, "10"); @@ -967,7 +967,7 @@ mod stats_tests { #[test] fn test_compute_stats_out_of_bounds_col() { let df = df! { "val" => [1i64] }.unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); // col index 99 is out of bounds — should return default without panic let stats = app.compute_stats(99); assert_eq!(stats.count, 0); @@ -982,7 +982,7 @@ mod unique_values_tests { "status" => ["active", "inactive", "active", "pending"], } .unwrap(); - App::new(df, "test.csv".to_string()) + App::new(df, "test.csv".to_string(), crate::theme::default_theme()) } #[test] @@ -1004,7 +1004,7 @@ mod unique_values_tests { &[Some("Alice"), None, Some("Alice"), None, None], ); let df = DataFrame::new(vec![s.into()]).unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.state.select_column(Some(0)); app.build_unique_values(); // nulls (3 of them) should be first (highest count) @@ -1024,7 +1024,7 @@ mod unique_values_tests { .unwrap() .finish() .unwrap(); - let mut app = App::new(df, "orders_nulls.csv".to_string()); + let mut app = App::new(df, "orders_nulls.csv".to_string(), crate::theme::default_theme()); // customer_name is col index 3 app.state.select_column(Some(3)); app.build_unique_values(); @@ -1086,7 +1086,7 @@ mod cycle_agg_tests { "sal" => [100i64], } .unwrap(); - App::new(df, "test.csv".to_string()) + App::new(df, "test.csv".to_string(), crate::theme::default_theme()) } #[test] @@ -1131,7 +1131,7 @@ mod cycle_agg_tests { "city" => ["NY", "LA"], } .unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); app.build_columns_profile(); app } diff --git a/src/browser/events.rs b/src/browser/events.rs index 32959ef..45fe09e 100644 --- a/src/browser/events.rs +++ b/src/browser/events.rs @@ -75,7 +75,7 @@ fn open_or_descend(app: &mut BrowserApp) { } else { match load_file_for_browser(&entry.path, app.backend.as_ref()) { Ok((df, title)) => { - app.viewer = Some(App::new(df, title)); + app.viewer = Some(App::new(df, title, crate::theme::default_theme())); app.focus = Focus::Viewer; app.status = None; } diff --git a/src/browser/ui.rs b/src/browser/ui.rs index 3bdf0b6..3f58d65 100644 --- a/src/browser/ui.rs +++ b/src/browser/ui.rs @@ -1,18 +1,14 @@ use crate::browser::app::{BrowserApp, Focus}; +use crate::theme::{default_theme, Theme}; use crate::ui::ui; -use catppuccin::PALETTE; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::Frame; -fn c(color: catppuccin::Color) -> Color { - Color::Rgb(color.rgb.r, color.rgb.g, color.rgb.b) -} - pub fn browser_ui(frame: &mut Frame, app: &mut BrowserApp) { - let m = &PALETTE.mocha.colors; + let theme: &Theme = default_theme(); let [content_area, bar_area] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(frame.area()); @@ -21,26 +17,26 @@ pub fn browser_ui(frame: &mut Frame, app: &mut BrowserApp) { let [browser_area, viewer_area] = Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]) .areas(content_area); - render_browser_pane(frame, app, browser_area, m); - render_viewer_pane(frame, app, viewer_area, m); + render_browser_pane(frame, app, browser_area, theme); + render_viewer_pane(frame, app, viewer_area, theme); } else { - render_viewer_pane(frame, app, content_area, m); + render_viewer_pane(frame, app, content_area, theme); } - frame.render_widget(Paragraph::new(browser_shortcut_bar(app, m)), bar_area); + frame.render_widget(Paragraph::new(browser_shortcut_bar(app, theme)), bar_area); } fn render_browser_pane( frame: &mut Frame, app: &BrowserApp, area: Rect, - m: &catppuccin::FlavorColors, + theme: &Theme, ) { let is_focused = app.focus == Focus::Browser; let border_style = if is_focused { - Style::default().fg(c(m.blue)) + Style::default().fg(theme.accent) } else { - Style::default().fg(c(m.overlay0)) + Style::default().fg(theme.border_idle) }; let title = truncate_path_left(&app.cwd, area.width.saturating_sub(4) as usize); @@ -48,7 +44,7 @@ fn render_browser_pane( .title(Line::from(title).alignment(Alignment::Left)) .borders(Borders::ALL) .border_style(border_style) - .style(Style::default().bg(c(m.mantle))); + .style(Style::default().bg(theme.bg_alt)); let inner = block.inner(area); frame.render_widget(block, area); @@ -62,7 +58,7 @@ fn render_browser_pane( }; if let (Some(msg), Some(sa)) = (&app.status, status_area) { - let status = Paragraph::new(msg.as_str()).style(Style::default().fg(c(m.red))); + let status = Paragraph::new(msg.as_str()).style(Style::default().fg(theme.error)); frame.render_widget(status, sa); } @@ -71,9 +67,9 @@ fn render_browser_pane( .iter() .map(|entry| { let style = if entry.is_dir { - Style::default().fg(c(m.blue)) + Style::default().fg(theme.accent) } else { - Style::default().fg(c(m.text)) + Style::default().fg(theme.fg) }; ListItem::new(entry.name.clone()).style(style) }) @@ -84,7 +80,7 @@ fn render_browser_pane( let list = List::new(items).highlight_style( Style::default() - .bg(c(m.surface1)) + .bg(theme.bg_sel) .add_modifier(Modifier::BOLD), ); @@ -95,24 +91,24 @@ fn render_viewer_pane( frame: &mut Frame, app: &mut BrowserApp, area: Rect, - m: &catppuccin::FlavorColors, + theme: &Theme, ) { if let Some(ref mut viewer) = app.viewer { ui(frame, viewer, area); } else { let hint = Paragraph::new("Navigate to a file and press Enter to open it") .alignment(Alignment::Center) - .style(Style::default().fg(c(m.overlay1))) + .style(Style::default().fg(theme.fg_muted)) .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(c(m.overlay0))), + .border_style(Style::default().fg(theme.border_idle)), ); frame.render_widget(hint, area); } } -fn browser_shortcut_bar<'a>(app: &BrowserApp, m: &catppuccin::FlavorColors) -> Line<'a> { +fn browser_shortcut_bar<'a>(app: &BrowserApp, theme: &Theme) -> Line<'a> { type Shortcuts = &'static [(&'static str, &'static str)]; let keys: Shortcuts = if !app.browser_visible { @@ -139,11 +135,11 @@ fn browser_shortcut_bar<'a>(app: &BrowserApp, m: &catppuccin::FlavorColors) -> L }; let key_style = Style::default() - .bg(c(m.blue)) - .fg(c(m.base)) + .bg(theme.accent) + .fg(theme.bg) .add_modifier(Modifier::BOLD); - let label_style = Style::default().bg(c(m.mantle)).fg(c(m.subtext0)); - let gap_style = Style::default().bg(c(m.mantle)); + let label_style = Style::default().bg(theme.bg_alt).fg(theme.fg_dim); + let gap_style = Style::default().bg(theme.bg_alt); let mut spans = Vec::new(); for (key, action) in keys { @@ -152,7 +148,7 @@ fn browser_shortcut_bar<'a>(app: &BrowserApp, m: &catppuccin::FlavorColors) -> L spans.push(Span::styled(" ", gap_style)); } - Line::from(spans).style(Style::default().bg(c(m.mantle))) + Line::from(spans).style(Style::default().bg(theme.bg_alt)) } /// Truncate a path from the left so it fits within `max_chars`, prefixing with `…`. @@ -219,8 +215,7 @@ mod tests { } fn bar_text(app: &crate::browser::app::BrowserApp) -> String { - let m = &catppuccin::PALETTE.mocha.colors; - let line = browser_shortcut_bar(app, m); + let line = browser_shortcut_bar(app, default_theme()); line.spans.iter().map(|s| s.content.as_ref()).collect() } @@ -264,7 +259,7 @@ mod tests { fn test_shortcut_bar_browser_focused_with_viewer_no_quit() { use polars::prelude::*; let df = df!("col" => &[1i64]).unwrap(); - let viewer = crate::app::App::new(df, "test.csv".to_string()); + let viewer = crate::app::App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let mut app = make_app(); app.viewer = Some(viewer); let text = bar_text(&app); diff --git a/src/main.rs b/src/main.rs index 57f9f37..2c203b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -269,7 +269,7 @@ fn main() -> Result<(), Box> { use std::io::IsTerminal; let cli = Cli::parse(); - let _theme: &'static theme::Theme = { + let theme: &'static theme::Theme = { let cli_theme = cli.theme.as_deref(); let env_theme = std::env::var("DATASIGHT_THEME").ok(); let state_theme = theme::state_path().and_then(|p| theme::read_state_theme_at(&p)); @@ -327,7 +327,7 @@ fn main() -> Result<(), Box> { } }; - let app = App::new(df, title); + let app = App::new(df, title, theme); ratatui::run(|terminal| run_app(terminal, app)) } diff --git a/src/theme.rs b/src/theme.rs index 3bf0a55..4022d69 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -11,6 +11,8 @@ pub struct Base16Scheme { #[derive(Debug)] pub struct Theme { pub name: &'static str, + /// Human-friendly name shown in the in-app theme picker (Task 10). + #[allow(dead_code)] pub display_name: &'static str, pub bg: Color, pub bg_alt: Color, @@ -320,6 +322,8 @@ pub fn read_state_theme_at(path: &Path) -> Option { Some(parsed.theme) } +/// Persist the chosen theme name to `state.toml`. Wired in by the in-app picker (Task 11). +#[allow(dead_code)] pub fn write_state_theme_at(path: &Path, name: &str) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -327,8 +331,7 @@ pub fn write_state_theme_at(path: &Path, name: &str) -> std::io::Result<()> { let state = StateFile { theme: name.to_string(), }; - let s = toml::to_string(&state) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + let s = toml::to_string(&state).map_err(std::io::Error::other)?; std::fs::write(path, s) } diff --git a/src/ui.rs b/src/ui.rs index c548c94..ca4c3f5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,14 +5,15 @@ //! overlays ([`render_stats_popup`], [`render_help_popup`], //! [`render_unique_values_popup`]). //! -//! All colors come from Catppuccin Mocha (`PALETTE.mocha`) via the thin [`c`] -//! helper. Viewport windowing is handled by [`count_visible_from`], which -//! computes how many columns fit a given terminal width starting from a column -//! offset. +//! Colors come from the active [`Theme`] resolved at startup; each renderer +//! receives `theme: &Theme` and reads semantic slots (`theme.bg`, `theme.accent`, +//! `theme.series[N]`, …). Viewport windowing is handled by [`count_visible_from`], +//! which computes how many columns fit a given terminal width starting from a +//! column offset. use crate::app::{AggFunc, App, ColumnProfile, Mode, PlotType, SortDirection}; use crate::config; -use catppuccin::PALETTE; +use crate::theme::Theme; use polars::prelude::{DataType, Series}; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -23,30 +24,26 @@ use ratatui::widgets::{ }; use ratatui::Frame; -fn c(color: catppuccin::Color) -> Color { - Color::Rgb(color.rgb.r, color.rgb.g, color.rgb.b) -} - const NULL_GLYPH: &str = "∅"; // None → muted ∅ glyph so a real null is distinguishable from an empty-string cell. -fn format_cell<'a>(value: Option<&str>, m: &catppuccin::FlavorColors) -> Cell<'a> { +fn format_cell<'a>(value: Option<&str>, theme: &Theme) -> Cell<'a> { match value { - None => Cell::from(NULL_GLYPH).style(Style::default().fg(c(m.overlay1))), + None => Cell::from(NULL_GLYPH).style(Style::default().fg(theme.fg_muted)), Some(s) => Cell::from(s.to_string()), } } pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { - let m = &PALETTE.mocha.colors; + let theme = app.theme; if matches!(app.mode, Mode::Plot) { - render_plot(frame, app, m); + render_plot(frame, app, theme); return; } if matches!(app.mode, Mode::ColumnsView) { - render_columns_view(frame, app, m); + render_columns_view(frame, app, theme); return; } @@ -102,11 +99,11 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { let header_cells = Row::new(vis_cols.iter().map(|&i| { Cell::from(app.header_label(i)).style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ) })) - .style(Style::default().bg(c(m.surface0))); + .style(Style::default().bg(theme.bg_alt)); // Pre-cast only the visible columns to String series. let all_columns = visible_view.get_columns(); @@ -124,9 +121,9 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { .map(|i| { let abs_row = app.viewport.row + i; let bg = if abs_row % 2 == 0 { - c(m.base) + theme.bg } else { - c(m.mantle) + theme.bg_alt }; Row::new( str_columns @@ -136,11 +133,11 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { .as_ref() .and_then(|series| series.str().ok()) .and_then(|ca| ca.get(i)); - format_cell(opt, m) + format_cell(opt, theme) }) .collect::>(), ) - .style(Style::default().bg(bg).fg(c(m.text))) + .style(Style::default().bg(bg).fg(theme.fg)) }) .collect(); @@ -154,22 +151,22 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { .block( Block::default() .title(format!(" {} ", app.file_path)) - .title_style(Style::default().fg(c(m.blue)).add_modifier(Modifier::BOLD)) + .title_style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.overlay0))) - .style(Style::default().bg(c(m.base))), + .border_style(Style::default().fg(theme.border_idle)) + .style(Style::default().bg(theme.bg)), ) - .row_highlight_style(Style::default().bg(c(m.surface0))) - .column_highlight_style(Style::default().bg(c(m.surface1))) + .row_highlight_style(Style::default().bg(theme.bg_alt)) + .column_highlight_style(Style::default().bg(theme.bg_sel)) .cell_highlight_style( Style::default() - .bg(c(m.blue)) - .fg(c(m.base)) + .bg(theme.accent) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ); - let (bar_text, bar_style) = get_bar(app, m); + let (bar_text, bar_style) = get_bar(app, theme); let bar = Paragraph::new(bar_text).style(bar_style); // Render with a temporary state. Column index is relative to the visible window. @@ -178,18 +175,18 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { render_state.select_column(Some(selected_col.saturating_sub(app.viewport.col))); frame.render_stateful_widget(table, chunks[0], &mut render_state); frame.render_widget(bar, chunks[1]); - frame.render_widget(Paragraph::new(shortcut_bar(app, m)), chunks[2]); + frame.render_widget(Paragraph::new(shortcut_bar(app, theme)), chunks[2]); if app.show_stats { - render_stats_popup(frame, app, m); + render_stats_popup(frame, app, theme); } if app.show_help { - render_help_popup(frame, app, m); + render_help_popup(frame, app, theme); } if matches!(app.mode, Mode::UniqueValues) { - render_unique_values_popup(frame, app, m); + render_unique_values_popup(frame, app, theme); } } @@ -211,7 +208,7 @@ fn count_visible_from(column_widths: &[u16], start: usize, available_w: usize) - count.max(1) } -fn render_stats_popup(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorColors) { +fn render_stats_popup(frame: &mut Frame, app: &mut App, theme: &Theme) { let col = app .state .selected_column() @@ -236,19 +233,19 @@ fn render_stats_popup(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorCo .block( Block::default() .title(" Column Stats ") - .title_style(Style::default().fg(c(m.mauve)).add_modifier(Modifier::BOLD)) + .title_style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.mauve))), + .border_style(Style::default().fg(theme.info)), ) - .style(Style::default().bg(c(m.surface0)).fg(c(m.text))); + .style(Style::default().bg(theme.bg_alt).fg(theme.fg)); frame.render_widget(popup, area); } -fn render_help_popup(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorColors) { +fn render_help_popup(frame: &mut Frame, app: &mut App, theme: &Theme) { let area = centered_rect(55, 80, frame.area()); frame.render_widget(Clear, area); - let text = help_text(m); + let text = help_text(theme); let total_lines = text.lines.len() as u16; let visible_lines = area.height.saturating_sub(2); // subtract top+bottom borders app.help_scroll = app @@ -260,19 +257,19 @@ fn render_help_popup(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorCol .title(" Help — j/k to scroll · ? or Esc to close ") .title_style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.lavender))), + .border_style(Style::default().fg(theme.info)), ) - .style(Style::default().bg(c(m.surface0)).fg(c(m.text))) + .style(Style::default().bg(theme.bg_alt).fg(theme.fg)) .scroll((app.help_scroll, 0)); frame.render_widget(popup, area); } -fn shortcut_bar<'a>(app: &App, m: &catppuccin::FlavorColors) -> Line<'a> { +fn shortcut_bar<'a>(app: &App, theme: &Theme) -> Line<'a> { // (primary, secondary) — primary keys are highlighted in blue, secondary in grey. // Secondary = always-valid base shortcuts not already shown in primary. type Shortcuts = &'static [(&'static str, &'static str)]; @@ -434,16 +431,16 @@ fn shortcut_bar<'a>(app: &App, m: &catppuccin::FlavorColors) -> Line<'a> { }; let primary_key = Style::default() - .bg(c(m.blue)) - .fg(c(m.base)) + .bg(theme.accent) + .fg(theme.bg) .add_modifier(Modifier::BOLD); let secondary_key = Style::default() - .bg(c(m.overlay0)) - .fg(c(m.base)) + .bg(theme.border_idle) + .fg(theme.bg) .add_modifier(Modifier::BOLD); - let label = Style::default().bg(c(m.mantle)).fg(c(m.subtext0)); - let gap = Style::default().bg(c(m.mantle)); - let sep = Style::default().bg(c(m.mantle)).fg(c(m.overlay0)); + let label = Style::default().bg(theme.bg_alt).fg(theme.fg_dim); + let gap = Style::default().bg(theme.bg_alt); + let sep = Style::default().bg(theme.bg_alt).fg(theme.border_idle); let mut spans = Vec::new(); @@ -463,10 +460,10 @@ fn shortcut_bar<'a>(app: &App, m: &catppuccin::FlavorColors) -> Line<'a> { spans.push(Span::styled(" ", gap)); } - Line::from(spans).style(Style::default().bg(c(m.mantle))) + Line::from(spans).style(Style::default().bg(theme.bg_alt)) } -fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { +fn get_bar(app: &App, theme: &Theme) -> (String, Style) { match app.mode { Mode::PlotPickY => { let y_names = if app.plot.y_cols.is_empty() { @@ -485,8 +482,8 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { y_names ), Style::default() - .bg(c(m.mauve)) - .fg(c(m.base)) + .bg(theme.info) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ) } @@ -504,8 +501,8 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { y_names ), Style::default() - .bg(c(m.mauve)) - .fg(c(m.base)) + .bg(theme.info) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ) } @@ -530,8 +527,8 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { } }, Style::default() - .bg(c(m.teal)) - .fg(c(m.base)) + .bg(theme.info) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ), Mode::ColumnsView => ( @@ -541,15 +538,15 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { " Column Inspector | / search | j/k navigate | Enter jump to column | Esc close ".to_string() }, Style::default() - .bg(c(m.green)) - .fg(c(m.base)) + .bg(theme.success) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ), Mode::Search => ( format!(" /{}_ ", app.search.query), Style::default() - .bg(c(m.yellow)) - .fg(c(m.base)) + .bg(theme.warn) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ), Mode::Filter => { @@ -557,16 +554,16 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { ( format!(" f {}_ — {} ", app.filter.query, err), Style::default() - .bg(c(m.red)) - .fg(c(m.base)) + .bg(theme.error) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ) } else { ( format!(" f {}_ (>,<,>=,<=,!=,= for numbers) ", app.filter.query), Style::default() - .bg(c(m.sapphire)) - .fg(c(m.base)) + .bg(theme.info) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ) } @@ -606,7 +603,7 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { agg_summary, app.view.height() ), - c(m.yellow), + theme.warn, ) } else if !app.groupby.keys.is_empty() { let key_names = app @@ -618,7 +615,7 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { .join(", "); ( format!(" GroupBy: {} | press B to execute ", key_names), - c(m.peach), + theme.warn, ) } else if !app.search.results.is_empty() { ( @@ -628,7 +625,7 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { app.search.results.len(), app.search.query ), - c(m.sky), + theme.info, ) } else if !app.filter.filters.is_empty() { let filter_summary = app @@ -652,11 +649,11 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { app.headers.len(), app.file_path ), - c(m.teal), + theme.info, ) } else if !app.sort.sorts.is_empty() { if let Some(ref err) = app.sort.error { - (format!(" Sort error: {} ", err), c(m.red)) + (format!(" Sort error: {} ", err), theme.error) } else { let sort_summary = app .sort @@ -687,11 +684,11 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { app.headers.len(), app.file_path ), - c(m.sapphire), + theme.info, ) } } else if let Some(ref err) = app.sort.error { - (format!(" Sort error: {} ", err), c(m.red)) + (format!(" Sort error: {} ", err), theme.error) } else { ( format!( @@ -706,30 +703,30 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (String, Style) { app.headers.len(), app.file_path ), - c(m.subtext1), + theme.fg_dim, ) }; - (text, Style::default().bg(c(m.surface0)).fg(fg)) + (text, Style::default().bg(theme.bg_alt).fg(fg)) } } } -fn help_text(m: &catppuccin::FlavorColors) -> Text<'static> { +fn help_text(theme: &Theme) -> Text<'static> { let section = |title: &'static str| { Line::from(vec![ Span::raw(" "), Span::styled( title, Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), ]) }; let key = |k: &'static str, desc: &'static str| { Line::from(vec![ - Span::styled(format!(" {:<14}", k), Style::default().fg(c(m.blue))), - Span::styled(desc, Style::default().fg(c(m.text))), + Span::styled(format!(" {:<14}", k), Style::default().fg(theme.accent)), + Span::styled(desc, Style::default().fg(theme.fg)), ]) }; Text::from(vec![ @@ -786,7 +783,7 @@ fn help_text(m: &catppuccin::FlavorColors) -> Text<'static> { ]) } -fn render_unique_values_popup(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorColors) { +fn render_unique_values_popup(frame: &mut Frame, app: &mut App, theme: &Theme) { let area = centered_rect(52, 70, frame.area()); frame.render_widget(Clear, area); @@ -808,11 +805,11 @@ fn render_unique_values_popup(frame: &mut Frame, app: &mut App, m: &catppuccin:: let outer = Block::default() .title(title) - .title_style(Style::default().fg(c(m.teal)).add_modifier(Modifier::BOLD)) + .title_style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.teal))) - .style(Style::default().bg(c(m.base))); + .border_style(Style::default().fg(theme.info)) + .style(Style::default().bg(theme.bg)); let inner = outer.inner(area); frame.render_widget(outer, area); @@ -826,17 +823,17 @@ fn render_unique_values_popup(frame: &mut Frame, app: &mut App, m: &catppuccin:: let (search_text, search_style) = if app.unique_values.searching { ( format!(" Search: {}_ ", app.unique_values.query), - Style::default().bg(c(m.surface0)).fg(c(m.text)), + Style::default().bg(theme.bg_alt).fg(theme.fg), ) } else if app.unique_values.query.is_empty() { ( " press / to search ".to_string(), - Style::default().bg(c(m.surface0)).fg(c(m.subtext0)), + Style::default().bg(theme.bg_alt).fg(theme.fg_dim), ) } else { ( format!(" Search: {} (press / to edit) ", app.unique_values.query), - Style::default().bg(c(m.surface0)).fg(c(m.subtext0)), + Style::default().bg(theme.bg_alt).fg(theme.fg_dim), ) }; frame.render_widget(Paragraph::new(search_text).style(search_style), zones[0]); @@ -845,16 +842,16 @@ fn render_unique_values_popup(frame: &mut Frame, app: &mut App, m: &catppuccin:: let header = Row::new([ Cell::from("Value").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Count").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), ]) - .style(Style::default().bg(c(m.surface0))) + .style(Style::default().bg(theme.bg_alt)) .bottom_margin(1); let rows: Vec = app @@ -863,10 +860,10 @@ fn render_unique_values_popup(frame: &mut Frame, app: &mut App, m: &catppuccin:: .iter() .enumerate() .map(|(i, (val, count))| { - let bg = if i % 2 == 0 { c(m.base) } else { c(m.mantle) }; + let bg = if i % 2 == 0 { theme.bg } else { theme.bg_alt }; Row::new([ - Cell::from(val.clone()).style(Style::default().fg(c(m.text))), - Cell::from(count.to_string()).style(Style::default().fg(c(m.subtext1))), + Cell::from(val.clone()).style(Style::default().fg(theme.fg)), + Cell::from(count.to_string()).style(Style::default().fg(theme.fg_dim)), ]) .style(Style::default().bg(bg)) }) @@ -876,15 +873,15 @@ fn render_unique_values_popup(frame: &mut Frame, app: &mut App, m: &catppuccin:: .header(header) .row_highlight_style( Style::default() - .bg(c(m.teal)) - .fg(c(m.base)) + .bg(theme.info) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ); frame.render_stateful_widget(table, zones[1], &mut app.unique_values.state); } -fn render_columns_view(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorColors) { +fn render_columns_view(frame: &mut Frame, app: &mut App, theme: &Theme) { let full_area = frame.area(); frame.render_widget(Clear, full_area); @@ -900,72 +897,72 @@ fn render_columns_view(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorC let (search_text, search_style) = if app.columns_view.searching { ( format!(" Search: {}_ ", app.columns_view.query), - Style::default().bg(c(m.surface0)).fg(c(m.text)), + Style::default().bg(theme.bg_alt).fg(theme.fg), ) } else if app.columns_view.query.is_empty() { ( " press / to search ".to_string(), - Style::default().bg(c(m.surface0)).fg(c(m.subtext0)), + Style::default().bg(theme.bg_alt).fg(theme.fg_dim), ) } else { ( format!(" Search: {} (press / to edit) ", app.columns_view.query), - Style::default().bg(c(m.surface0)).fg(c(m.subtext0)), + Style::default().bg(theme.bg_alt).fg(theme.fg_dim), ) }; frame.render_widget(Paragraph::new(search_text).style(search_style), chunks[0]); - let (bar_text, bar_style) = get_bar(app, m); + let (bar_text, bar_style) = get_bar(app, theme); frame.render_widget(Paragraph::new(bar_text).style(bar_style), chunks[2]); let header = Row::new([ Cell::from("Column").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Type").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Count").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Nulls").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Unique").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Min").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Max").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Mean").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), Cell::from("Median").style( Style::default() - .fg(c(m.lavender)) + .fg(theme.info) .add_modifier(Modifier::BOLD), ), ]) - .style(Style::default().bg(c(m.surface0))) + .style(Style::default().bg(theme.bg_alt)) .bottom_margin(1); let rows: Vec = app @@ -977,7 +974,7 @@ fn render_columns_view(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorC app.columns_view .profile .get(idx) - .map(|p| profile_row(p, i, m)) + .map(|p| profile_row(p, i, theme)) }) .collect(); @@ -1005,56 +1002,47 @@ fn render_columns_view(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorC .block( Block::default() .title(title) - .title_style(Style::default().fg(c(m.green)).add_modifier(Modifier::BOLD)) + .title_style(Style::default().fg(theme.success).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.overlay0))) - .style(Style::default().bg(c(m.base))), + .border_style(Style::default().fg(theme.border_idle)) + .style(Style::default().bg(theme.bg)), ) .row_highlight_style( Style::default() - .bg(c(m.green)) - .fg(c(m.base)) + .bg(theme.success) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ); frame.render_stateful_widget(table, chunks[1], &mut app.columns_view.state); } -fn profile_row<'a>(p: &'a ColumnProfile, idx: usize, m: &catppuccin::FlavorColors) -> Row<'a> { - let bg = if idx % 2 == 0 { c(m.base) } else { c(m.mantle) }; +fn profile_row<'a>(p: &'a ColumnProfile, idx: usize, theme: &Theme) -> Row<'a> { + let bg = if idx % 2 == 0 { theme.bg } else { theme.bg_alt }; let null_style = if p.null_count > 0 { - Style::default().fg(c(m.red)) + Style::default().fg(theme.error) } else { - Style::default().fg(c(m.text)) + Style::default().fg(theme.fg) }; Row::new([ - Cell::from(p.name.clone()).style(Style::default().fg(c(m.text))), - Cell::from(p.dtype.clone()).style(Style::default().fg(c(m.subtext1))), - Cell::from(p.count.to_string()).style(Style::default().fg(c(m.text))), + Cell::from(p.name.clone()).style(Style::default().fg(theme.fg)), + Cell::from(p.dtype.clone()).style(Style::default().fg(theme.fg_dim)), + Cell::from(p.count.to_string()).style(Style::default().fg(theme.fg)), Cell::from(p.null_count.to_string()).style(null_style), - Cell::from(p.unique.to_string()).style(Style::default().fg(c(m.text))), - Cell::from(p.min.clone()).style(Style::default().fg(c(m.subtext1))), - Cell::from(p.max.clone()).style(Style::default().fg(c(m.subtext1))), + Cell::from(p.unique.to_string()).style(Style::default().fg(theme.fg)), + Cell::from(p.min.clone()).style(Style::default().fg(theme.fg_dim)), + Cell::from(p.max.clone()).style(Style::default().fg(theme.fg_dim)), Cell::from(p.mean.map_or("—".to_string(), |v| format!("{:.2}", v))) - .style(Style::default().fg(c(m.blue))), + .style(Style::default().fg(theme.accent)), Cell::from(p.median.map_or("—".to_string(), |v| format!("{:.2}", v))) - .style(Style::default().fg(c(m.blue))), + .style(Style::default().fg(theme.accent)), ]) .style(Style::default().bg(bg)) } -fn series_color(idx: usize, m: &catppuccin::FlavorColors) -> Color { - match idx % 8 { - 0 => c(m.blue), - 1 => c(m.green), - 2 => c(m.red), - 3 => c(m.yellow), - 4 => c(m.mauve), - 5 => c(m.peach), - 6 => c(m.teal), - _ => c(m.lavender), - } +fn series_color(idx: usize, theme: &Theme) -> Color { + theme.series[idx % theme.series.len()] } fn downsample(data: Vec<(f64, f64)>, max_points: usize) -> Vec<(f64, f64)> { @@ -1112,7 +1100,7 @@ fn compute_histogram(app: &App, y_idx: usize) -> Result, String> fn render_histogram( frame: &mut Frame, app: &App, - m: &catppuccin::FlavorColors, + theme: &Theme, y_idx: usize, full_area: Rect, ) { @@ -1126,7 +1114,7 @@ fn render_histogram( let bar_text = " Histogram chart | t cycle line/bar/histogram | Esc / p to close ".to_string(); frame.render_widget( - Paragraph::new(bar_text).style(Style::default().bg(c(m.surface0)).fg(c(m.subtext1))), + Paragraph::new(bar_text).style(Style::default().bg(theme.bg_alt).fg(theme.fg_dim)), bar_area, ); @@ -1137,12 +1125,12 @@ fn render_histogram( .block( Block::default() .title(" Plot Error ") - .title_style(Style::default().fg(c(m.red)).add_modifier(Modifier::BOLD)) + .title_style(Style::default().fg(theme.error).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.red))), + .border_style(Style::default().fg(theme.error)), ) - .style(Style::default().bg(c(m.base)).fg(c(m.text))); + .style(Style::default().bg(theme.bg).fg(theme.fg)); frame.render_widget(paragraph, chart_area); return; } @@ -1165,30 +1153,30 @@ fn render_histogram( .name(app.headers[y_idx].as_str()) .marker(symbols::Marker::Braille) .graph_type(GraphType::Bar) - .style(Style::default().fg(c(m.mauve))) + .style(Style::default().fg(theme.info)) .data(&data); let chart = Chart::new(vec![dataset]) .block( Block::default() .title(format!(" Distribution of {} ", app.headers[y_idx])) - .title_style(Style::default().fg(c(m.mauve)).add_modifier(Modifier::BOLD)) + .title_style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.overlay0))) - .style(Style::default().bg(c(m.base))), + .border_style(Style::default().fg(theme.border_idle)) + .style(Style::default().bg(theme.bg)), ) .x_axis( Axis::default() .title(app.headers[y_idx].as_str()) - .style(Style::default().fg(c(m.subtext1))) + .style(Style::default().fg(theme.fg_dim)) .labels(x_labels) .bounds([x_min, x_max]), ) .y_axis( Axis::default() .title("Count") - .style(Style::default().fg(c(m.subtext1))) + .style(Style::default().fg(theme.fg_dim)) .labels(numeric_axis_labels( 0.0, y_max + y_pad, @@ -1200,7 +1188,7 @@ fn render_histogram( frame.render_widget(chart, chart_area); } -fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { +fn render_plot(frame: &mut Frame, app: &App, theme: &Theme) { let full_area = frame.area(); frame.render_widget(Clear, full_area); @@ -1210,7 +1198,7 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { // Histogram: single-column only; use first Y col. if matches!(app.plot.plot_type, PlotType::Histogram) { - render_histogram(frame, app, m, app.plot.y_cols[0], full_area); + render_histogram(frame, app, theme, app.plot.y_cols[0], full_area); return; } @@ -1278,7 +1266,7 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { app.plot_type_label(), cycle_hint )) - .style(Style::default().bg(c(m.surface0)).fg(c(m.subtext1))), + .style(Style::default().bg(theme.bg_alt).fg(theme.fg_dim)), bar_area, ); @@ -1287,12 +1275,12 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { .block( Block::default() .title(" Plot Error ") - .title_style(Style::default().fg(c(m.red)).add_modifier(Modifier::BOLD)) + .title_style(Style::default().fg(theme.error).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.red))), + .border_style(Style::default().fg(theme.error)), ) - .style(Style::default().bg(c(m.base)).fg(c(m.text))); + .style(Style::default().bg(theme.bg).fg(theme.fg)); frame.render_widget(msg, chart_area); return; } @@ -1328,7 +1316,7 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { Dataset::default() .marker(symbols::Marker::Braille) .graph_type(graph_type) - .style(Style::default().fg(series_color(*series_idx, m))) + .style(Style::default().fg(series_color(*series_idx, theme))) .data(data) }) .collect(); @@ -1356,18 +1344,18 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { .title(format!(" {} vs {} ", title_y, x_header)) .title_style( Style::default() - .fg(series_color(0, m)) + .fg(series_color(0, theme)) .add_modifier(Modifier::BOLD), ) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.overlay0))) - .style(Style::default().bg(c(m.base))), + .border_style(Style::default().fg(theme.border_idle)) + .style(Style::default().bg(theme.bg)), ) .x_axis( Axis::default() .title(x_header) - .style(Style::default().fg(c(m.subtext1))) + .style(Style::default().fg(theme.fg_dim)) .bounds([x_min, x_max]), ) .y_axis( @@ -1377,7 +1365,7 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { } else { "Value" }) - .style(Style::default().fg(c(m.subtext1))) + .style(Style::default().fg(theme.fg_dim)) .labels(y_labels) .bounds(y_bounds), ); @@ -1386,7 +1374,7 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { // Legend for multi-series plots. if app.plot.y_cols.len() > 1 { - render_plot_legend(frame, app, m, chart_area); + render_plot_legend(frame, app, theme, chart_area); } if !x_labels.is_empty() && label_area.height > 0 { @@ -1397,7 +1385,7 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { chart_area, label_area, y_label_width, - c(m.subtext1), + theme.fg_dim, ); } } @@ -1405,7 +1393,7 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { fn render_plot_legend( frame: &mut Frame, app: &App, - m: &catppuccin::FlavorColors, + theme: &Theme, chart_area: Rect, ) { let legend_inner_w = app @@ -1443,8 +1431,8 @@ fn render_plot_legend( .enumerate() .map(|(i, &y_idx)| { Line::from(vec![ - Span::styled("● ", Style::default().fg(series_color(i, m))), - Span::styled(app.headers[y_idx].as_str(), Style::default().fg(c(m.text))), + Span::styled("● ", Style::default().fg(series_color(i, theme))), + Span::styled(app.headers[y_idx].as_str(), Style::default().fg(theme.fg)), ]) }) .collect(); @@ -1453,8 +1441,8 @@ fn render_plot_legend( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(c(m.overlay0))) - .style(Style::default().bg(c(m.base))), + .border_style(Style::default().fg(theme.border_idle)) + .style(Style::default().bg(theme.bg)), ); frame.render_widget(legend, legend_area); } @@ -1716,7 +1704,7 @@ mod histogram_tests { "val" => [1.0f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], } .unwrap(); - App::new(df, "test.csv".to_string()) + App::new(df, "test.csv".to_string(), crate::theme::default_theme()) } #[test] @@ -1738,7 +1726,7 @@ mod histogram_tests { "name" => ["alice", "bob", "charlie"], } .unwrap(); - let app = App::new(df, "test.csv".to_string()); + let app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let result = compute_histogram_pub(&app, 0); assert!(result.is_err()); assert!(result.unwrap_err().contains("numeric")); @@ -1751,7 +1739,7 @@ mod histogram_tests { "val" => [5.0f64, 5.0, 5.0], } .unwrap(); - let app = App::new(df, "test.csv".to_string()); + let app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let result = compute_histogram_pub(&app, 0); assert!(result.is_ok()); let data = result.unwrap(); @@ -1773,7 +1761,7 @@ mod histogram_tests { .cast(&DataType::Decimal(Some(10), Some(2))) .unwrap(); let df = DataFrame::new(vec![s.into()]).unwrap(); - let app = App::new(df, "test.parquet".to_string()); + let app = App::new(df, "test.parquet".to_string(), crate::theme::default_theme()); let result = compute_histogram_pub(&app, 0); assert!( result.is_ok(), @@ -1843,7 +1831,7 @@ mod indexed_plot_tests { "val" => [10.0f64, 20.0, 30.0], } .unwrap(); - let app = App::new(df, "test.csv".to_string()); + let app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let data = extract_plot_data_indexed(&app, 0); assert_eq!(data, vec![(0.0, 10.0), (1.0, 20.0), (2.0, 30.0)]); } @@ -1854,7 +1842,7 @@ mod indexed_plot_tests { // position so gaps are visually preserved. let s = Series::new("val".into(), &[Some(10.0f64), None, Some(30.0)]); let df = DataFrame::new(vec![s.into()]).unwrap(); - let app = App::new(df, "test.csv".to_string()); + let app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let data = extract_plot_data_indexed(&app, 0); assert_eq!(data, vec![(0.0, 10.0), (2.0, 30.0)]); } @@ -1865,7 +1853,7 @@ mod indexed_plot_tests { "name" => ["alice", "bob"], } .unwrap(); - let app = App::new(df, "test.csv".to_string()); + let app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); assert!(extract_plot_data_indexed(&app, 0).is_empty()); } } @@ -1936,7 +1924,7 @@ mod null_render_tests { // One column, three rows: a value, an empty string, a real null. let s = Series::new("col".into(), &[Some("alice"), Some(""), None]); let df = DataFrame::new(vec![s.into()]).unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let backend = TestBackend::new(40, 10); let mut terminal = Terminal::new(backend).unwrap(); @@ -1945,7 +1933,7 @@ mod null_render_tests { .unwrap(); let buffer = terminal.backend().buffer(); - let expected_fg = c(PALETTE.mocha.colors.overlay1); + let expected_fg = crate::theme::default_theme().fg_muted; let area = buffer.area; let mut null_cells = 0; @@ -1970,7 +1958,7 @@ mod null_render_tests { // Empty strings must stay blank — only real nulls get the glyph. let s = Series::new("col".into(), &[Some("alice"), Some(""), Some("bob")]); let df = DataFrame::new(vec![s.into()]).unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let backend = TestBackend::new(40, 10); let mut terminal = Terminal::new(backend).unwrap(); @@ -1998,7 +1986,7 @@ mod null_render_tests { // render as ∅ rather than a blank cell. let s = Series::new("val".into(), &[Some(1i64), None, Some(3)]); let df = DataFrame::new(vec![s.into()]).unwrap(); - let mut app = App::new(df, "test.csv".to_string()); + let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let backend = TestBackend::new(40, 10); let mut terminal = Terminal::new(backend).unwrap(); From 3c6ec63c8f865b83234531a585241957fcb2c19e Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:53:57 +0200 Subject: [PATCH 09/20] feat(theme): wire Theme through BrowserApp Add `theme: &'static Theme` to BrowserApp, propagate from main.rs and into the embedded viewer when a file is opened. browser_ui now reads `app.theme` instead of the temporary `default_theme()` placeholder. Co-Authored-By: Claude Sonnet 4.6 --- src/browser/app.rs | 28 ++++++++++++++++++++++++---- src/browser/events.rs | 2 +- src/browser/ui.rs | 12 ++++++++---- src/main.rs | 2 +- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/browser/app.rs b/src/browser/app.rs index a9d0454..a06daf3 100644 --- a/src/browser/app.rs +++ b/src/browser/app.rs @@ -1,5 +1,6 @@ use crate::app::App; use crate::browser::{Entry, FileBrowser}; +use crate::theme::Theme; pub struct BrowserApp { pub backend: Box, @@ -11,6 +12,7 @@ pub struct BrowserApp { pub focus: Focus, pub status: Option, pub should_quit: bool, + pub theme: &'static Theme, } #[derive(Debug, PartialEq)] @@ -20,7 +22,11 @@ pub enum Focus { } impl BrowserApp { - pub fn new(backend: Box, root_path: String) -> Self { + pub fn new( + backend: Box, + root_path: String, + theme: &'static Theme, + ) -> Self { let (entries, status) = match backend.list(&root_path) { Ok(e) => (e, None), Err(e) => (Vec::new(), Some(e.to_string())), @@ -35,6 +41,7 @@ impl BrowserApp { focus: Focus::Browser, status, should_quit: false, + theme, } } @@ -130,7 +137,11 @@ mod tests { } fn make_app(entries: Vec) -> BrowserApp { - BrowserApp::new(Box::new(StubBackend { entries }), "/test/root".to_string()) + BrowserApp::new( + Box::new(StubBackend { entries }), + "/test/root".to_string(), + crate::theme::default_theme(), + ) } fn file_entry(name: &str) -> Entry { @@ -235,6 +246,7 @@ mod tests { let mut app = BrowserApp::new( Box::new(StubBackend { entries: vec![] }), "/test/root/child".to_string(), + crate::theme::default_theme(), ); app.ascend(); assert_eq!(app.cwd, "/test/root"); @@ -242,14 +254,22 @@ mod tests { #[test] fn test_ascend_no_op_at_local_root() { - let mut app = BrowserApp::new(Box::new(StubBackend { entries: vec![] }), "/".to_string()); + let mut app = BrowserApp::new( + Box::new(StubBackend { entries: vec![] }), + "/".to_string(), + crate::theme::default_theme(), + ); app.ascend(); assert_eq!(app.cwd, "/"); } #[test] fn test_new_sets_status_on_list_error() { - let app = BrowserApp::new(Box::new(ErrorBackend), "/nonexistent".to_string()); + let app = BrowserApp::new( + Box::new(ErrorBackend), + "/nonexistent".to_string(), + crate::theme::default_theme(), + ); assert!(app.status.is_some(), "status should be set on list error"); assert!(app.entries.is_empty(), "entries should be empty on error"); } diff --git a/src/browser/events.rs b/src/browser/events.rs index 45fe09e..7a903a2 100644 --- a/src/browser/events.rs +++ b/src/browser/events.rs @@ -75,7 +75,7 @@ fn open_or_descend(app: &mut BrowserApp) { } else { match load_file_for_browser(&entry.path, app.backend.as_ref()) { Ok((df, title)) => { - app.viewer = Some(App::new(df, title, crate::theme::default_theme())); + app.viewer = Some(App::new(df, title, app.theme)); app.focus = Focus::Viewer; app.status = None; } diff --git a/src/browser/ui.rs b/src/browser/ui.rs index 3f58d65..baba273 100644 --- a/src/browser/ui.rs +++ b/src/browser/ui.rs @@ -1,5 +1,5 @@ use crate::browser::app::{BrowserApp, Focus}; -use crate::theme::{default_theme, Theme}; +use crate::theme::Theme; use crate::ui::ui; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; @@ -8,7 +8,7 @@ use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::Frame; pub fn browser_ui(frame: &mut Frame, app: &mut BrowserApp) { - let theme: &Theme = default_theme(); + let theme: &Theme = app.theme; let [content_area, bar_area] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(frame.area()); @@ -211,11 +211,15 @@ mod tests { } fn make_app() -> crate::browser::app::BrowserApp { - crate::browser::app::BrowserApp::new(Box::new(StubBackend), "/test".to_string()) + crate::browser::app::BrowserApp::new( + Box::new(StubBackend), + "/test".to_string(), + crate::theme::default_theme(), + ) } fn bar_text(app: &crate::browser::app::BrowserApp) -> String { - let line = browser_shortcut_bar(app, default_theme()); + let line = browser_shortcut_bar(app, crate::theme::default_theme()); line.spans.iter().map(|s| s.content.as_ref()).collect() } diff --git a/src/main.rs b/src/main.rs index 2c203b1..e24a9c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -288,7 +288,7 @@ fn main() -> Result<(), Box> { eprintln!("Error: {}", err); std::process::exit(1); }); - let browser_app = browser::app::BrowserApp::new(backend, resolved); + let browser_app = browser::app::BrowserApp::new(backend, resolved, theme); return ratatui::run(|terminal| browser::events::run_browser_app(terminal, browser_app)); } From 9a08b01d5d4232673a8f23575442f15252a3acfd Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 20:55:58 +0200 Subject: [PATCH 10/20] chore: remove catppuccin crate dependency All theme colors now come from the in-repo Base16 schemes in src/theme.rs; the Catppuccin Mocha palette is one of nine built-in schemes. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 12 ------------ Cargo.toml | 1 - 2 files changed, 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02ff855..77566eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,17 +309,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "catppuccin" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa1798ea21d5f88f057b9a4d075bbf3e9cc4bba98da0d6197a2019f61f633bf4" -dependencies = [ - "itertools 0.13.0", - "serde", - "serde_json", -] - [[package]] name = "cc" version = "1.2.56" @@ -614,7 +603,6 @@ dependencies = [ name = "datasight" version = "0.5.0" dependencies = [ - "catppuccin", "clap", "crossterm", "dirs", diff --git a/Cargo.toml b/Cargo.toml index d851829..6b7a5b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ aws = ["dep:object_store", "object_store/aws", "dep:tokio"] crossterm = "0.29.0" ratatui = "0.30.0" polars = { version = "0.46", features = ["csv", "parquet", "lazy", "strings", "regex", "json", "temporal", "dtype-date", "dtype-datetime", "dtype-decimal"] } -catppuccin = "2" clap = { version = "4", features = ["derive"] } dirs = "5" serde = { version = "1", features = ["derive"] } From d84876a28abefb5df7529b75c06c2e7096f2fc57 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 21:03:55 +0200 Subject: [PATCH 11/20] feat(theme): add ThemePicker struct and popup renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemePicker holds a cursor into list_themes() and remembers the original theme name so Esc can revert. render_picker draws a centered popup with the theme list, current selection highlight, and a key-hint footer. Wired into App in Task 11 — module-level #![allow(dead_code)] is removed there. Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 1 + src/theme_picker.rs | 156 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/theme_picker.rs diff --git a/src/main.rs b/src/main.rs index e24a9c5..ea16149 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod browser; mod config; mod events; mod theme; +mod theme_picker; mod ui; use app::App; diff --git a/src/theme_picker.rs b/src/theme_picker.rs new file mode 100644 index 0000000..1a2162f --- /dev/null +++ b/src/theme_picker.rs @@ -0,0 +1,156 @@ +// dead_code lifted in Task 11, when the picker is wired into App's keymap. +#![allow(dead_code)] + +use crate::theme::{default_theme, list_themes, theme_by_name, Theme}; + +pub struct ThemePicker { + pub cursor: usize, + pub original: &'static str, +} + +impl ThemePicker { + pub fn open(current: &'static Theme) -> Self { + let cursor = list_themes() + .iter() + .position(|t| t.name == current.name) + .unwrap_or(0); + Self { + cursor, + original: current.name, + } + } + + pub fn move_up(&mut self) -> &'static Theme { + let n = list_themes().len(); + self.cursor = if self.cursor == 0 { + n - 1 + } else { + self.cursor - 1 + }; + self.current() + } + + pub fn move_down(&mut self) -> &'static Theme { + let n = list_themes().len(); + self.cursor = (self.cursor + 1) % n; + self.current() + } + + pub fn current(&self) -> &'static Theme { + &list_themes()[self.cursor] + } + + pub fn original_theme(&self) -> &'static Theme { + theme_by_name(self.original).unwrap_or_else(default_theme) + } +} + +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; + +pub fn render_picker(frame: &mut Frame, area: Rect, picker: &ThemePicker, theme: &Theme) { + let popup_w = 36u16.min(area.width.saturating_sub(2)); + let popup_h = 13u16.min(area.height.saturating_sub(2)); + let x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup = Rect { + x, + y, + width: popup_w, + height: popup_h, + }; + + frame.render_widget(Clear, popup); + + let block = Block::default() + .title(Line::from(" Theme ").style(Style::default().fg(theme.accent))) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent)) + .style(Style::default().bg(theme.bg_alt).fg(theme.fg)); + + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let [list_area, footer_area] = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); + + let items: Vec = list_themes() + .iter() + .map(|t| { + ListItem::new(Line::from(vec![ + Span::styled(format!(" {:14}", t.name), Style::default().fg(theme.fg)), + Span::styled( + format!(" {}", t.display_name), + Style::default().fg(theme.fg_dim), + ), + ])) + }) + .collect(); + + let mut state = ListState::default(); + state.select(Some(picker.cursor)); + + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.bg_sel) + .add_modifier(Modifier::BOLD), + ); + + frame.render_stateful_widget(list, list_area, &mut state); + + let footer = Paragraph::new(Line::from(vec![ + Span::styled(" j/k ", Style::default().bg(theme.accent).fg(theme.bg)), + Span::styled(" navigate ", Style::default().fg(theme.fg_dim)), + Span::styled(" Enter ", Style::default().bg(theme.accent).fg(theme.bg)), + Span::styled(" keep ", Style::default().fg(theme.fg_dim)), + Span::styled(" Esc ", Style::default().bg(theme.accent).fg(theme.bg)), + Span::styled(" cancel", Style::default().fg(theme.fg_dim)), + ])) + .alignment(Alignment::Left) + .style(Style::default().bg(theme.bg_alt)); + frame.render_widget(footer, footer_area); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_starts_cursor_at_current_theme() { + let nord = theme_by_name("nord").unwrap(); + let picker = ThemePicker::open(nord); + assert_eq!(picker.current().name, "nord"); + assert_eq!(picker.original, "nord"); + } + + #[test] + fn move_down_advances_and_wraps() { + let mut picker = ThemePicker::open(default_theme()); + let n = list_themes().len(); + for _ in 0..n - 1 { + picker.move_down(); + } + let _ = picker.move_down(); + assert_eq!(picker.cursor, 0); + } + + #[test] + fn move_up_retreats_and_wraps() { + let mut picker = ThemePicker::open(default_theme()); + let last = picker.move_up(); + assert_eq!(picker.cursor, list_themes().len() - 1); + assert_eq!(last.name, list_themes().last().unwrap().name); + } + + #[test] + fn original_theme_returns_starting_theme() { + let dracula = theme_by_name("dracula").unwrap(); + let mut picker = ThemePicker::open(dracula); + picker.move_down(); + picker.move_down(); + assert_eq!(picker.original_theme().name, "dracula"); + } +} From 7fed60e8fb2f6c82ad7c1ae2dd53da5c0194060f Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 21:06:32 +0200 Subject: [PATCH 12/20] feat(theme): add picker mode in App with live preview and persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T opens the picker; j/k navigate with live preview (App.theme reassigned on each move); Enter persists the choice to ~/.config/datasight/state.toml and exits; Esc reverts to the original theme. Mode::ThemePicker is added with PartialEq so the matches! → == switch in ui.rs reads naturally. Drops the temporary `#[allow(dead_code)]` on display_name, write_state_theme_at, and the theme_picker module — all wired in now. Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 6 +++++- src/events.rs | 33 +++++++++++++++++++++++++++++++++ src/theme.rs | 6 ++---- src/theme_picker.rs | 3 --- src/ui.rs | 14 ++++++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7334b58..fa677ba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,6 +12,7 @@ use crate::config; use crate::theme::Theme; +use crate::theme_picker::ThemePicker; use polars::prelude::*; use ratatui::widgets::TableState; use std::collections::{HashMap, HashSet}; @@ -28,7 +29,7 @@ pub struct ColumnProfile { pub median: Option, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum Mode { Search, Normal, @@ -38,6 +39,7 @@ pub enum Mode { Plot, ColumnsView, UniqueValues, + ThemePicker, } #[derive(Debug, Default, Clone, PartialEq)] @@ -164,6 +166,7 @@ pub struct App { pub columns_view: ColumnsViewState, pub viewport: ViewportState, pub theme: &'static Theme, + pub picker: Option, } /// Strips a leading comparison operator from `query`. @@ -349,6 +352,7 @@ impl App { columns_view: ColumnsViewState::default(), viewport: ViewportState::default(), theme, + picker: None, }; if !app.df.is_empty() { app.state.select(Some(0)); diff --git a/src/events.rs b/src/events.rs index 4e0e804..20d4946 100644 --- a/src/events.rs +++ b/src/events.rs @@ -103,8 +103,41 @@ pub(crate) fn dispatch_viewer_key(app: &mut App, key: &event::KeyEvent) { app.build_unique_values(); app.mode = Mode::UniqueValues; } + event::KeyCode::Char('T') => { + app.picker = Some(crate::theme_picker::ThemePicker::open(app.theme)); + app.mode = Mode::ThemePicker; + } _ => {} }, + Mode::ThemePicker => { + if let Some(picker) = app.picker.as_mut() { + match key.code { + event::KeyCode::Char('j') | event::KeyCode::Down => { + app.theme = picker.move_down(); + } + event::KeyCode::Char('k') | event::KeyCode::Up => { + app.theme = picker.move_up(); + } + event::KeyCode::Enter => { + if let Some(path) = crate::theme::state_path() { + if let Err(e) = + crate::theme::write_state_theme_at(&path, app.theme.name) + { + eprintln!("warning: could not save theme to {:?}: {}", path, e); + } + } + app.picker = None; + app.mode = Mode::Normal; + } + event::KeyCode::Esc => { + app.theme = picker.original_theme(); + app.picker = None; + app.mode = Mode::Normal; + } + _ => {} + } + } + } Mode::Search => match key.code { event::KeyCode::Backspace => pop_char_from_search_query(app), event::KeyCode::Enter => to_first_search_query_result(app), diff --git a/src/theme.rs b/src/theme.rs index 4022d69..3959ffe 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -11,8 +11,7 @@ pub struct Base16Scheme { #[derive(Debug)] pub struct Theme { pub name: &'static str, - /// Human-friendly name shown in the in-app theme picker (Task 10). - #[allow(dead_code)] + /// Human-friendly name shown in the in-app theme picker. pub display_name: &'static str, pub bg: Color, pub bg_alt: Color, @@ -322,8 +321,7 @@ pub fn read_state_theme_at(path: &Path) -> Option { Some(parsed.theme) } -/// Persist the chosen theme name to `state.toml`. Wired in by the in-app picker (Task 11). -#[allow(dead_code)] +/// Persist the chosen theme name to `state.toml`. pub fn write_state_theme_at(path: &Path, name: &str) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; diff --git a/src/theme_picker.rs b/src/theme_picker.rs index 1a2162f..02863d3 100644 --- a/src/theme_picker.rs +++ b/src/theme_picker.rs @@ -1,6 +1,3 @@ -// dead_code lifted in Task 11, when the picker is wired into App's keymap. -#![allow(dead_code)] - use crate::theme::{default_theme, list_themes, theme_by_name, Theme}; pub struct ThemePicker { diff --git a/src/ui.rs b/src/ui.rs index ca4c3f5..de06828 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -188,6 +188,12 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { if matches!(app.mode, Mode::UniqueValues) { render_unique_values_popup(frame, app, theme); } + + if app.mode == Mode::ThemePicker { + if let Some(ref picker) = app.picker { + crate::theme_picker::render_picker(frame, area, picker, app.theme); + } + } } /// Returns the number of columns that fit within `available_w` terminal cells @@ -382,6 +388,10 @@ fn shortcut_bar<'a>(app: &App, theme: &Theme) -> Line<'a> { ), // render_plot() returns early in ui() and renders its own status bar. Mode::Plot => unreachable!("shortcut_bar is not called in Plot mode"), + Mode::ThemePicker => ( + &[("j / k", "Navigate"), ("Enter", "Keep"), ("Esc", "Cancel")], + &[], + ), Mode::ColumnsView => { if app.columns_view.searching { ( @@ -508,6 +518,10 @@ fn get_bar(app: &App, theme: &Theme) -> (String, Style) { } // render_plot() returns early in ui() and renders its own status bar. Mode::Plot => unreachable!("get_bar is not called in Plot mode"), + Mode::ThemePicker => ( + format!(" Theme: {} | j/k navigate | Enter keep | Esc cancel ", app.theme.name), + Style::default().bg(theme.accent).fg(theme.bg).add_modifier(Modifier::BOLD), + ), Mode::UniqueValues => ( { let col = app From f33651b274579fca497cfa571fc893e2fd5cb8bd Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 21:08:02 +0200 Subject: [PATCH 13/20] feat(theme): add picker support in browse mode T (uppercase) opens the picker globally; the handler runs before the ctrl-e and Tab interceptors so the picker takes precedence. j/k previews update both BrowserApp.theme and the embedded viewer.theme so both panes re-render in the new theme. Enter persists; Esc reverts. Co-Authored-By: Claude Sonnet 4.6 --- src/browser/app.rs | 3 +++ src/browser/events.rs | 51 +++++++++++++++++++++++++++++++++++++++++++ src/browser/ui.rs | 4 ++++ 3 files changed, 58 insertions(+) diff --git a/src/browser/app.rs b/src/browser/app.rs index a06daf3..3a41058 100644 --- a/src/browser/app.rs +++ b/src/browser/app.rs @@ -1,6 +1,7 @@ use crate::app::App; use crate::browser::{Entry, FileBrowser}; use crate::theme::Theme; +use crate::theme_picker::ThemePicker; pub struct BrowserApp { pub backend: Box, @@ -13,6 +14,7 @@ pub struct BrowserApp { pub status: Option, pub should_quit: bool, pub theme: &'static Theme, + pub picker: Option, } #[derive(Debug, PartialEq)] @@ -42,6 +44,7 @@ impl BrowserApp { status, should_quit: false, theme, + picker: None, } } diff --git a/src/browser/events.rs b/src/browser/events.rs index 7a903a2..bfc96e1 100644 --- a/src/browser/events.rs +++ b/src/browser/events.rs @@ -15,6 +15,57 @@ pub fn run_browser_app( if let event::Event::Key(key) = event::read()? { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + // Theme picker takes precedence over all other browse-mode keys. + if app.picker.is_some() { + if let Some(picker) = app.picker.as_mut() { + match key.code { + event::KeyCode::Char('j') | event::KeyCode::Down => { + let next = picker.move_down(); + app.theme = next; + if let Some(ref mut viewer) = app.viewer { + viewer.theme = next; + } + } + event::KeyCode::Char('k') | event::KeyCode::Up => { + let prev = picker.move_up(); + app.theme = prev; + if let Some(ref mut viewer) = app.viewer { + viewer.theme = prev; + } + } + event::KeyCode::Enter => { + if let Some(path) = crate::theme::state_path() { + if let Err(e) = + crate::theme::write_state_theme_at(&path, app.theme.name) + { + eprintln!( + "warning: could not save theme to {:?}: {}", + path, e + ); + } + } + app.picker = None; + } + event::KeyCode::Esc => { + let original = picker.original_theme(); + app.theme = original; + if let Some(ref mut viewer) = app.viewer { + viewer.theme = original; + } + app.picker = None; + } + _ => {} + } + } + continue; + } + + // T (uppercase) opens the picker. + if key.code == event::KeyCode::Char('T') { + app.picker = Some(crate::theme_picker::ThemePicker::open(app.theme)); + continue; + } + // ctrl-e: toggle browser sidebar visibility. if ctrl && key.code == event::KeyCode::Char('e') { app.browser_visible = !app.browser_visible; diff --git a/src/browser/ui.rs b/src/browser/ui.rs index baba273..2896f83 100644 --- a/src/browser/ui.rs +++ b/src/browser/ui.rs @@ -24,6 +24,10 @@ pub fn browser_ui(frame: &mut Frame, app: &mut BrowserApp) { } frame.render_widget(Paragraph::new(browser_shortcut_bar(app, theme)), bar_area); + + if let Some(ref picker) = app.picker { + crate::theme_picker::render_picker(frame, frame.area(), picker, app.theme); + } } fn render_browser_pane( From 61ab871bd919b1d126bc83a1fdf43ede0594fdbb Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 21:34:04 +0200 Subject: [PATCH 14/20] test(qa): exercise theme picker in qa.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Suite Y that opens the picker with T, verifies the popup is visible and cancellable with Esc, then re-opens, navigates with j, and persists with Enter — checking ~/.config/datasight/state.toml ends up with a theme= entry. Covers both the file-viewer and browse entry points. Cleans up the state file at the end. The block also resets cwd to REPO_ROOT first, since X6 leaves the shell in tests/fixtures. --- qa.sh | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/qa.sh b/qa.sh index b9cb9ba..22eb0e0 100755 --- a/qa.sh +++ b/qa.sh @@ -562,6 +562,66 @@ assert_contains "X6/browse-cwd-default" "orders.csv" send "q" 0.20 tmux send-keys -t "$APP_PANE" "cd $REPO_ROOT" Enter; sleep 0.2 +# ── Suite Y: Theme picker ───────────────────────────────────────────────────── +echo "" +echo "=== Suite Y: Theme picker ===" + +# Start clean — remove any persisted state from a prior run. +STATE_FILE="${HOME}/.config/datasight/state.toml" +rm -f "$STATE_FILE" + +# Reset cwd to repo root in case earlier suites left it elsewhere. +tmux send-keys -t "$APP_PANE" C-c; sleep 0.10 +tmux send-keys -t "$APP_PANE" C-u; sleep 0.05 +tmux send-keys -t "$APP_PANE" "cd $REPO_ROOT" Enter; sleep 0.20 + +# Y1: T opens the picker; the popup lists Base16 themes +start_app "tests/fixtures/orders.csv" +send "T" 0.60 +assert_contains "Y1/picker-title" "Theme" +assert_contains "Y1/picker-list" "nord" + +# Y2: Esc cancels the picker — popup gone, no state file written +esc +assert_not_contains "Y2/picker-closed" "nord" +if [ ! -f "$STATE_FILE" ]; then + echo " PASS [Y2/no-state-after-cancel]" + PASS=$((PASS + 1)) +else + echo " FAIL [Y2/no-state-after-cancel] — state.toml written despite Esc" + FAIL=$((FAIL + 1)) + FAILURES+=("[Y2/no-state-after-cancel] state.toml exists after cancel") +fi +quit + +# Y3: Re-open picker, navigate down once, Enter persists the choice +start_app "tests/fixtures/orders.csv" +send "T" 0.60 +send "j" 0.15 +enter 0.30 +assert_contains "Y3/picker-closed" "order_id" +quit + +# Y4: State file exists and contains a theme name +if [ -f "$STATE_FILE" ] && grep -q '^theme *=' "$STATE_FILE"; then + echo " PASS [Y4/state-persisted]" + PASS=$((PASS + 1)) +else + echo " FAIL [Y4/state-persisted] — expected theme= in $STATE_FILE" + FAIL=$((FAIL + 1)) + FAILURES+=("[Y4/state-persisted] state file missing or malformed") +fi + +# Y5: T also opens the picker in browse mode +start_app "browse tests/fixtures/" +send "T" 0.60 +assert_contains "Y5/browse-picker-open" "Theme" +esc +send "q" 0.20 + +# Reset theme state so the next QA run (and the dev's normal use) starts fresh. +rm -f "$STATE_FILE" + # ── Summary ──────────────────────────────────────────────────────────────────── echo "" echo "════════════════════════════════════════" From eb34516d4d4c0e6b390f57be0d69495172417597 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 21:35:22 +0200 Subject: [PATCH 15/20] docs: document Base16 theme system and picker keybind Updates README with a Themes section, the T keybind, a Base16-themed Features bullet, and refreshed tagline. CLAUDE.md gets entries for theme.rs and theme_picker.rs, the new ThemePicker mode variant, and a note on how browse mode keeps the viewer's theme in sync. --- CLAUDE.md | 6 ++++-- README.md | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0b89bce..27caf1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,9 @@ Source files under `src/`: - **`app_tests.rs`** — Unit tests for `app.rs`, loaded via `#[path]` so they share `app`'s private scope (`FilterQuery`, `parse_operator`, etc.) without requiring visibility changes. - **`config.rs`** — Application-wide numeric constants (`DEFAULT_COLUMN_WIDTH`, `PAGE_SCROLL_AMOUNT`, etc.). - **`events.rs`** — The main event loop (`run_app`). Reads crossterm key events and dispatches to `App` methods or small helper functions based on `app.mode`. -- **`ui.rs`** — All ratatui rendering. Uses Catppuccin Mocha (`PALETTE.mocha`) for colors via a thin `c()` helper. `count_visible_from()` handles horizontal viewport windowing; `ViewportState` tracks `row`/`col` offsets so large files stay fast. +- **`ui.rs`** — All ratatui rendering. Resolves colors from the active `&'static Theme` (`app.theme`) — no hardcoded palette references. `count_visible_from()` handles horizontal viewport windowing; `ViewportState` tracks `row`/`col` offsets so large files stay fast. +- **`theme.rs`** — Base16 theme system. Owns 9 built-in `Base16Scheme` constants, the semantic `Theme` struct (slot-based: `bg`, `bg_alt`, `accent`, `error`, `series[6]`, etc.), state file I/O at `~/.config/datasight/state.toml`, and the `resolve_theme(cli, env, state)` precedence function (CLI > env > state file > `mocha`). +- **`theme_picker.rs`** — `ThemePicker` state (cursor + original theme name) and `render_picker()` popup helper, used by both `App` (plain mode) and `BrowserApp` (browse mode). ### State sub-structs @@ -69,7 +71,7 @@ Source files under `src/`: ### Mode state machine -`Mode` variants (defined in `app.rs`): `Normal`, `Search`, `Filter`, `PlotPickY`, `PlotPickX`, `Plot`, `ColumnsView`, `UniqueValues`. The event loop in `events.rs` matches on `app.mode` first; `ui.rs` branches on mode to render the appropriate full-screen view or popup overlay. +`Mode` variants (defined in `app.rs`): `Normal`, `Search`, `Filter`, `PlotPickY`, `PlotPickX`, `Plot`, `ColumnsView`, `UniqueValues`, `ThemePicker`. The event loop in `events.rs` matches on `app.mode` first; `ui.rs` branches on mode to render the appropriate full-screen view or popup overlay. In browse mode (`BrowserApp`), the picker is gated by an `Option` field instead of a mode variant, and `BrowserApp` propagates its `&'static Theme` to the viewer's `App` on file load and on every picker key event so live preview stays in sync across both panes. ### Data flow diff --git a/README.md b/README.md index 356deef..deb16a6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # datasight — a fast terminal viewer for CSV, Parquet, and JSON -**A vim-keybinded TUI for exploring tabular data from the command line.** Browse, filter, sort, group, and plot CSV, TSV, Parquet, JSON, and NDJSON files directly in your terminal — no spreadsheet, no notebook, no web UI required. Built in Rust on [ratatui](https://ratatui.rs) and themed with [Catppuccin Mocha](https://github.com/catppuccin/catppuccin). +**A vim-keybinded TUI for exploring tabular data from the command line.** Browse, filter, sort, group, and plot CSV, TSV, Parquet, JSON, and NDJSON files directly in your terminal — no spreadsheet, no notebook, no web UI required. Built in Rust on [ratatui](https://ratatui.rs), with 9 built-in [Base16](https://github.com/chriskempson/base16) color themes. [![CI](https://github.com/SpollaL/datasight/actions/workflows/ci.yml/badge.svg)](https://github.com/SpollaL/datasight/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) @@ -20,7 +20,7 @@ - Column stats popup (`e`) - Automatic date detection for ISO (`YYYY-MM-DD`) and common non-ISO formats (`MM/DD/YYYY`, `DD-Mon-YYYY`), with an ambiguity guard for slash-dates - In-app help popup (`?`) -- Catppuccin Mocha color theme with zebra-striped rows and mode-aware status bar +- 9 built-in Base16 themes — `--theme nord`, `gruvbox-dark`, `dracula`, etc., switchable live with `T` and persisted across runs - Supports CSV, TSV, Parquet, JSON (`[{...}]`), and NDJSON/JSON Lines (`.ndjson`, `.jsonl`) files - Custom delimiter support via `-d` flag — works with pipe-separated, semicolon-separated, and any single-character delimiter - Pipe-friendly — reads from stdin with automatic format detection (CSV, JSON, NDJSON) @@ -109,6 +109,34 @@ Azure reads `AZURE_STORAGE_CONNECTION_STRING` or individual `AZURE_STORAGE_ACCOU | `Tab` | Switch focus browser ↔ viewer | | `ctrl-e` | Toggle browser sidebar | | `q` | Quit (when no file is open) | +## Themes + +datasight ships with 9 Base16 color themes: + +| Name | Display | +|---|---| +| `mocha` *(default)* | Catppuccin Mocha | +| `latte` | Catppuccin Latte | +| `frappe` | Catppuccin Frappé | +| `macchiato` | Catppuccin Macchiato | +| `gruvbox-dark` | Gruvbox Dark | +| `nord` | Nord | +| `dracula` | Dracula | +| `solarized-dark` | Solarized Dark | +| `tokyo-night` | Tokyo Night | + +Pick one at startup: + +``` +datasight --theme nord file.csv +DATASIGHT_THEME=dracula datasight file.csv +``` + +Or switch live: press **`T`** to open the theme picker, `j`/`k` to browse with +live preview, `Enter` to confirm (saves to `~/.config/datasight/state.toml`), +`Esc` to revert. + +Precedence: `--theme` > `DATASIGHT_THEME` > saved state file > `mocha` (default). ## Keybindings @@ -219,6 +247,7 @@ For histogram, the Y column is binned automatically — no X column selection ne | `=` | Autofit all columns | | `e` | Toggle column stats popup | | `?` | Toggle help popup | +| `T` | Open theme picker | | `q` | Quit | ## Troubleshooting From 9b3ac8982af07e9bab5ab8c0831f4e4c348c3a80 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Wed, 6 May 2026 21:36:17 +0200 Subject: [PATCH 16/20] style: cargo fmt across theme refactor --- src/app_tests.rs | 6 +- src/browser/app.rs | 6 +- src/browser/events.rs | 5 +- src/browser/ui.rs | 17 ++---- src/ui.rs | 138 +++++++++++++++--------------------------- 5 files changed, 61 insertions(+), 111 deletions(-) diff --git a/src/app_tests.rs b/src/app_tests.rs index 145b069..91b323f 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -1024,7 +1024,11 @@ mod unique_values_tests { .unwrap() .finish() .unwrap(); - let mut app = App::new(df, "orders_nulls.csv".to_string(), crate::theme::default_theme()); + let mut app = App::new( + df, + "orders_nulls.csv".to_string(), + crate::theme::default_theme(), + ); // customer_name is col index 3 app.state.select_column(Some(3)); app.build_unique_values(); diff --git a/src/browser/app.rs b/src/browser/app.rs index 3a41058..a1726f4 100644 --- a/src/browser/app.rs +++ b/src/browser/app.rs @@ -24,11 +24,7 @@ pub enum Focus { } impl BrowserApp { - pub fn new( - backend: Box, - root_path: String, - theme: &'static Theme, - ) -> Self { + pub fn new(backend: Box, root_path: String, theme: &'static Theme) -> Self { let (entries, status) = match backend.list(&root_path) { Ok(e) => (e, None), Err(e) => (Vec::new(), Some(e.to_string())), diff --git a/src/browser/events.rs b/src/browser/events.rs index bfc96e1..76604f7 100644 --- a/src/browser/events.rs +++ b/src/browser/events.rs @@ -38,10 +38,7 @@ pub fn run_browser_app( if let Err(e) = crate::theme::write_state_theme_at(&path, app.theme.name) { - eprintln!( - "warning: could not save theme to {:?}: {}", - path, e - ); + eprintln!("warning: could not save theme to {:?}: {}", path, e); } } app.picker = None; diff --git a/src/browser/ui.rs b/src/browser/ui.rs index 2896f83..a79cce0 100644 --- a/src/browser/ui.rs +++ b/src/browser/ui.rs @@ -30,12 +30,7 @@ pub fn browser_ui(frame: &mut Frame, app: &mut BrowserApp) { } } -fn render_browser_pane( - frame: &mut Frame, - app: &BrowserApp, - area: Rect, - theme: &Theme, -) { +fn render_browser_pane(frame: &mut Frame, app: &BrowserApp, area: Rect, theme: &Theme) { let is_focused = app.focus == Focus::Browser; let border_style = if is_focused { Style::default().fg(theme.accent) @@ -91,12 +86,7 @@ fn render_browser_pane( frame.render_stateful_widget(list, list_area, &mut list_state); } -fn render_viewer_pane( - frame: &mut Frame, - app: &mut BrowserApp, - area: Rect, - theme: &Theme, -) { +fn render_viewer_pane(frame: &mut Frame, app: &mut BrowserApp, area: Rect, theme: &Theme) { if let Some(ref mut viewer) = app.viewer { ui(frame, viewer, area); } else { @@ -267,7 +257,8 @@ mod tests { fn test_shortcut_bar_browser_focused_with_viewer_no_quit() { use polars::prelude::*; let df = df!("col" => &[1i64]).unwrap(); - let viewer = crate::app::App::new(df, "test.csv".to_string(), crate::theme::default_theme()); + let viewer = + crate::app::App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let mut app = make_app(); app.viewer = Some(viewer); let text = bar_text(&app); diff --git a/src/ui.rs b/src/ui.rs index de06828..29e9289 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -97,11 +97,8 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { let vis_cols: Vec = (app.viewport.col..total_cols).take(vis_count).collect(); let header_cells = Row::new(vis_cols.iter().map(|&i| { - Cell::from(app.header_label(i)).style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ) + Cell::from(app.header_label(i)) + .style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)) })) .style(Style::default().bg(theme.bg_alt)); @@ -151,7 +148,11 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { .block( Block::default() .title(format!(" {} ", app.file_path)) - .title_style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)) + .title_style( + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border_idle)) @@ -261,11 +262,7 @@ fn render_help_popup(frame: &mut Frame, app: &mut App, theme: &Theme) { .block( Block::default() .title(" Help — j/k to scroll · ? or Esc to close ") - .title_style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ) + .title_style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.info)), @@ -519,8 +516,14 @@ fn get_bar(app: &App, theme: &Theme) -> (String, Style) { // render_plot() returns early in ui() and renders its own status bar. Mode::Plot => unreachable!("get_bar is not called in Plot mode"), Mode::ThemePicker => ( - format!(" Theme: {} | j/k navigate | Enter keep | Esc cancel ", app.theme.name), - Style::default().bg(theme.accent).fg(theme.bg).add_modifier(Modifier::BOLD), + format!( + " Theme: {} | j/k navigate | Enter keep | Esc cancel ", + app.theme.name + ), + Style::default() + .bg(theme.accent) + .fg(theme.bg) + .add_modifier(Modifier::BOLD), ), Mode::UniqueValues => ( { @@ -731,9 +734,7 @@ fn help_text(theme: &Theme) -> Text<'static> { Span::raw(" "), Span::styled( title, - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), ), ]) }; @@ -854,16 +855,8 @@ fn render_unique_values_popup(frame: &mut Frame, app: &mut App, theme: &Theme) { // Values table let header = Row::new([ - Cell::from("Value").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Count").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), + Cell::from("Value").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Count").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), ]) .style(Style::default().bg(theme.bg_alt)) .bottom_margin(1); @@ -930,51 +923,15 @@ fn render_columns_view(frame: &mut Frame, app: &mut App, theme: &Theme) { frame.render_widget(Paragraph::new(bar_text).style(bar_style), chunks[2]); let header = Row::new([ - Cell::from("Column").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Type").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Count").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Nulls").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Unique").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Min").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Max").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Mean").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Median").style( - Style::default() - .fg(theme.info) - .add_modifier(Modifier::BOLD), - ), + Cell::from("Column").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Type").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Count").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Nulls").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Unique").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Min").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Max").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Mean").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Median").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), ]) .style(Style::default().bg(theme.bg_alt)) .bottom_margin(1); @@ -1016,7 +973,11 @@ fn render_columns_view(frame: &mut Frame, app: &mut App, theme: &Theme) { .block( Block::default() .title(title) - .title_style(Style::default().fg(theme.success).add_modifier(Modifier::BOLD)) + .title_style( + Style::default() + .fg(theme.success) + .add_modifier(Modifier::BOLD), + ) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border_idle)) @@ -1111,13 +1072,7 @@ fn compute_histogram(app: &App, y_idx: usize) -> Result, String> .collect()) } -fn render_histogram( - frame: &mut Frame, - app: &App, - theme: &Theme, - y_idx: usize, - full_area: Rect, -) { +fn render_histogram(frame: &mut Frame, app: &App, theme: &Theme, y_idx: usize, full_area: Rect) { let zones = Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(1)]) @@ -1139,7 +1094,11 @@ fn render_histogram( .block( Block::default() .title(" Plot Error ") - .title_style(Style::default().fg(theme.error).add_modifier(Modifier::BOLD)) + .title_style( + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD), + ) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.error)), @@ -1289,7 +1248,11 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme) { .block( Block::default() .title(" Plot Error ") - .title_style(Style::default().fg(theme.error).add_modifier(Modifier::BOLD)) + .title_style( + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD), + ) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.error)), @@ -1404,12 +1367,7 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme) { } } -fn render_plot_legend( - frame: &mut Frame, - app: &App, - theme: &Theme, - chart_area: Rect, -) { +fn render_plot_legend(frame: &mut Frame, app: &App, theme: &Theme, chart_area: Rect) { let legend_inner_w = app .plot .y_cols @@ -1775,7 +1733,11 @@ mod histogram_tests { .cast(&DataType::Decimal(Some(10), Some(2))) .unwrap(); let df = DataFrame::new(vec![s.into()]).unwrap(); - let app = App::new(df, "test.parquet".to_string(), crate::theme::default_theme()); + let app = App::new( + df, + "test.parquet".to_string(), + crate::theme::default_theme(), + ); let result = compute_histogram_pub(&app, 0); assert!( result.is_ok(), From 5ea17477e404703165d8e89f645cfe88e21f2955 Mon Sep 17 00:00:00 2001 From: luca spolladore Date: Wed, 6 May 2026 22:07:55 +0200 Subject: [PATCH 17/20] docs: fix and complete keybinding docs in help popup and README - Fix help_text: p entered pick-Y not pick-X; add Space entry; clarify Enter behaviour per mode; expand t to mention histogram for single-Y - Add Theme section to help popup (T, j/k, Enter, Esc) - README: add missing `i` (row-index X) to Plot table - README: add missing `T` (theme picker) to Browse keybindings table Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 ++ src/ui.rs | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index deb16a6..8fe9134 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Azure reads `AZURE_STORAGE_CONNECTION_STRING` or individual `AZURE_STORAGE_ACCOU | `Esc` | Go up to parent | | `Tab` | Switch focus browser ↔ viewer | | `ctrl-e` | Toggle browser sidebar | +| `T` | Open theme picker | | `q` | Quit (when no file is open) | ## Themes @@ -211,6 +212,7 @@ Active sorts are shown in the header with `①▲` / `②▼` glyphs and summari | `h` / `←` / `l` / `→` | Pick-Y / Pick-X | Move between columns | | `Space` | Pick-Y | Toggle the current column as a Y series (select one or many) | | `Enter` | Pick-Y | Confirm Y selection and advance to pick-X | +| `i` | Pick-Y | Use row index as X and render the chart immediately (skips pick-X) | | `Enter` | Pick-X | Confirm X column and render the chart | | `Esc` | Pick-Y / Pick-X | Cancel (pick-X goes back to pick-Y; pick-Y returns to normal) | | `t` | Plot | Cycle chart type (line → bar → histogram for single-Y; line ↔ bar for multi-Y) | diff --git a/src/ui.rs b/src/ui.rs index 29e9289..9cca125 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -779,13 +779,20 @@ fn help_text(theme: &Theme) -> Text<'static> { key("B", "Execute / clear group-by"), Line::raw(""), section("Plot"), - key("p", "Mark column as Y, enter pick-X mode"), - key("←/→ h/l", "Navigate to X column (pick-X mode)"), - key("Enter", "Confirm X column, show chart"), - key("i", "Plot against row index (skip pick-X mode)"), - key("t", "Toggle line / bar chart"), + key("p", "Mark column as Y, enter pick-Y mode"), + key("←/→ h/l", "Navigate columns (pick-Y / pick-X)"), + key("Space", "Toggle Y column (pick-Y)"), + key("Enter", "pick-Y: advance to pick-X | pick-X: show chart"), + key("i", "Plot against row index (skip pick-X)"), + key("t", "Cycle chart type (line → bar → histogram; line ↔ bar for multi-Y)"), key("Esc / p", "Close chart"), Line::raw(""), + section("Theme"), + key("T", "Open theme picker"), + key("j / k", "Navigate themes (live preview)"), + key("Enter", "Keep selected theme (saved to disk)"), + key("Esc", "Cancel — restore previous theme"), + Line::raw(""), section("Other"), key("u", "Unique values popup (searchable, Enter to filter)"), key("i", "Column Inspector (schema + stats)"), From 983031c32ff2a0eb3eb78ac2bf255573a62efd25 Mon Sep 17 00:00:00 2001 From: luca spolladore Date: Wed, 6 May 2026 22:17:36 +0200 Subject: [PATCH 18/20] docs(ui): clarify PlotPickY shortcut hints for i and Enter "Use row index as X" didn't convey that i skips X selection and plots immediately. Renamed to "Plot now (row index as X)" and updated "Pick X" to "Pick X axis" for symmetry. Co-Authored-By: Claude Sonnet 4.6 --- src/ui.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 9cca125..d62d4a7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -373,8 +373,8 @@ fn shortcut_bar<'a>(app: &App, theme: &Theme) -> Line<'a> { &[ ("← →", "Navigate"), ("Space", "Toggle Y"), - ("Enter", "Pick X"), - ("i", "Use row index as X"), + ("Enter", "Pick X axis"), + ("i", "Plot now (row index as X)"), ("Esc", "Cancel"), ], &[], @@ -784,7 +784,10 @@ fn help_text(theme: &Theme) -> Text<'static> { key("Space", "Toggle Y column (pick-Y)"), key("Enter", "pick-Y: advance to pick-X | pick-X: show chart"), key("i", "Plot against row index (skip pick-X)"), - key("t", "Cycle chart type (line → bar → histogram; line ↔ bar for multi-Y)"), + key( + "t", + "Cycle chart type (line → bar → histogram; line ↔ bar for multi-Y)", + ), key("Esc / p", "Close chart"), Line::raw(""), section("Theme"), From 1871cfb0a60557e7595341bea46cfe1893abea44 Mon Sep 17 00:00:00 2001 From: luca spolladore Date: Wed, 6 May 2026 22:22:28 +0200 Subject: [PATCH 19/20] docs(ui): surface i shortcut in PlotPickY primary bar The i key was only visible in the shortcut badge row and could be cut off on narrow terminals. Added it to the bold info bar where users look first, and shortened the badge label to "Plot with index". Co-Authored-By: Claude Sonnet 4.6 --- src/ui.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index d62d4a7..e0daf17 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -374,7 +374,7 @@ fn shortcut_bar<'a>(app: &App, theme: &Theme) -> Line<'a> { ("← →", "Navigate"), ("Space", "Toggle Y"), ("Enter", "Pick X axis"), - ("i", "Plot now (row index as X)"), + ("i", "Plot with index"), ("Esc", "Cancel"), ], &[], @@ -485,7 +485,7 @@ fn get_bar(app: &App, theme: &Theme) -> (String, Style) { }; ( format!( - " Y: [{}] — Space toggle · ←/→ navigate · Enter pick X · Esc cancel ", + " Y: [{}] — Space toggle · ←/→ navigate · i plot with index · Enter pick X · Esc cancel ", y_names ), Style::default() From 6142dcf6f98466b5ee28d04e90ef4a56a7eabc7e Mon Sep 17 00:00:00 2001 From: luca spolladore Date: Wed, 6 May 2026 22:28:37 +0200 Subject: [PATCH 20/20] fix: add area to render plot to not cover browser --- src/ui.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index e0daf17..0d299be 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -38,12 +38,12 @@ pub fn ui(frame: &mut Frame, app: &mut App, area: Rect) { let theme = app.theme; if matches!(app.mode, Mode::Plot) { - render_plot(frame, app, theme); + render_plot(frame, app, theme, area); return; } if matches!(app.mode, Mode::ColumnsView) { - render_columns_view(frame, app, theme); + render_columns_view(frame, app, theme, area); return; } @@ -898,8 +898,7 @@ fn render_unique_values_popup(frame: &mut Frame, app: &mut App, theme: &Theme) { frame.render_stateful_widget(table, zones[1], &mut app.unique_values.state); } -fn render_columns_view(frame: &mut Frame, app: &mut App, theme: &Theme) { - let full_area = frame.area(); +fn render_columns_view(frame: &mut Frame, app: &mut App, theme: &Theme, full_area: Rect) { frame.render_widget(Clear, full_area); let chunks = Layout::default() @@ -1171,8 +1170,7 @@ fn render_histogram(frame: &mut Frame, app: &App, theme: &Theme, y_idx: usize, f frame.render_widget(chart, chart_area); } -fn render_plot(frame: &mut Frame, app: &App, theme: &Theme) { - let full_area = frame.area(); +fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { frame.render_widget(Clear, full_area); if app.plot.y_cols.is_empty() {