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/ 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/Cargo.lock b/Cargo.lock index 3d10606..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,13 +603,16 @@ dependencies = [ name = "datasight" version = "0.5.0" dependencies = [ - "catppuccin", "clap", "crossterm", + "dirs", "object_store", "polars", "ratatui", + "serde", + "tempfile", "tokio", + "toml", ] [[package]] @@ -676,6 +668,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 +786,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 +1483,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 +1780,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 +2832,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 +3132,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 +3450,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 +3661,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 +4302,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 +4347,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 +4395,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 +4413,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 +4431,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 +4461,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 +4479,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 +4497,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 +4515,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 +4533,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..6b7a5b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,16 @@ 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"] } +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/README.md b/README.md index 356deef..8fe9134 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) @@ -108,7 +108,36 @@ 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 + +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 @@ -183,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) | @@ -219,6 +249,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 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 "════════════════════════════════════════" diff --git a/src/app.rs b/src/app.rs index 43c6296..fa677ba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,8 @@ //! mode to choose what to render. use crate::config; +use crate::theme::Theme; +use crate::theme_picker::ThemePicker; use polars::prelude::*; use ratatui::widgets::TableState; use std::collections::{HashMap, HashSet}; @@ -27,7 +29,7 @@ pub struct ColumnProfile { pub median: Option, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum Mode { Search, Normal, @@ -37,6 +39,7 @@ pub enum Mode { Plot, ColumnsView, UniqueValues, + ThemePicker, } #[derive(Debug, Default, Clone, PartialEq)] @@ -162,6 +165,8 @@ pub struct App { pub unique_values: UniqueValuesState, pub columns_view: ColumnsViewState, pub viewport: ViewportState, + pub theme: &'static Theme, + pub picker: Option, } /// Strips a leading comparison operator from `query`. @@ -317,7 +322,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 +351,8 @@ impl App { unique_values: UniqueValuesState::default(), columns_view: ColumnsViewState::default(), viewport: ViewportState::default(), + theme, + picker: None, }; if !app.df.is_empty() { app.state.select(Some(0)); diff --git a/src/app_tests.rs b/src/app_tests.rs index 67fc617..91b323f 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,11 @@ 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 +1090,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 +1135,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/app.rs b/src/browser/app.rs index a9d0454..a1726f4 100644 --- a/src/browser/app.rs +++ b/src/browser/app.rs @@ -1,5 +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, @@ -11,6 +13,8 @@ pub struct BrowserApp { pub focus: Focus, pub status: Option, pub should_quit: bool, + pub theme: &'static Theme, + pub picker: Option, } #[derive(Debug, PartialEq)] @@ -20,7 +24,7 @@ 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 +39,8 @@ impl BrowserApp { focus: Focus::Browser, status, should_quit: false, + theme, + picker: None, } } @@ -130,7 +136,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 +245,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 +253,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 32959ef..76604f7 100644 --- a/src/browser/events.rs +++ b/src/browser/events.rs @@ -15,6 +15,54 @@ 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; @@ -75,7 +123,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, app.theme)); app.focus = Focus::Viewer; app.status = None; } diff --git a/src/browser/ui.rs b/src/browser/ui.rs index 3bdf0b6..a79cce0 100644 --- a/src/browser/ui.rs +++ b/src/browser/ui.rs @@ -1,18 +1,14 @@ use crate::browser::app::{BrowserApp, Focus}; +use crate::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 = app.theme; let [content_area, bar_area] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(frame.area()); @@ -21,26 +17,25 @@ 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); + + if let Some(ref picker) = app.picker { + crate::theme_picker::render_picker(frame, frame.area(), picker, app.theme); + } } -fn render_browser_pane( - frame: &mut Frame, - app: &BrowserApp, - area: Rect, - m: &catppuccin::FlavorColors, -) { +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(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 +43,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 +57,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 +66,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,35 +79,30 @@ 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), ); frame.render_stateful_widget(list, list_area, &mut list_state); } -fn render_viewer_pane( - frame: &mut Frame, - app: &mut BrowserApp, - area: Rect, - m: &catppuccin::FlavorColors, -) { +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 { 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 +129,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 +142,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 `…`. @@ -215,12 +205,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 m = &catppuccin::PALETTE.mocha.colors; - let line = browser_shortcut_bar(app, m); + let line = browser_shortcut_bar(app, crate::theme::default_theme()); line.spans.iter().map(|s| s.content.as_ref()).collect() } @@ -264,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()); + 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/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/main.rs b/src/main.rs index 8064141..ea16149 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ mod app; mod browser; mod config; mod events; +mod theme; +mod theme_picker; mod ui; use app::App; @@ -23,6 +25,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, } @@ -265,13 +270,26 @@ 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| { 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)); } @@ -310,7 +328,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 new file mode 100644 index 0000000..3959ffe --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,529 @@ +use ratatui::style::Color; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub struct Base16Scheme { + pub name: &'static str, + pub display_name: &'static str, + pub base: [[u8; 3]; 16], +} + +#[derive(Debug)] +pub struct Theme { + pub name: &'static str, + /// Human-friendly name shown in the in-app theme picker. + 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], + ], +}; + +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], + ], +}; + +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(", ") +} + +#[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) +} + +/// 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)?; + } + let state = StateFile { + theme: name.to_string(), + }; + let s = toml::to_string(&state).map_err(std::io::Error::other)?; + 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::*; + + #[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(_, _, _))); + } + } + + #[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); + } + } + + #[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"); + } + + #[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")); + } + + #[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"); + } +} diff --git a/src/theme_picker.rs b/src/theme_picker.rs new file mode 100644 index 0000000..02863d3 --- /dev/null +++ b/src/theme_picker.rs @@ -0,0 +1,153 @@ +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"); + } +} diff --git a/src/ui.rs b/src/ui.rs index c548c94..0d299be 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, area); return; } if matches!(app.mode, Mode::ColumnsView) { - render_columns_view(frame, app, m); + render_columns_view(frame, app, theme, area); return; } @@ -100,13 +97,10 @@ 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(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ) + Cell::from(app.header_label(i)) + .style(Style::default().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 +118,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 +130,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 +148,26 @@ 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 +176,24 @@ 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); + } + + if app.mode == Mode::ThemePicker { + if let Some(ref picker) = app.picker { + crate::theme_picker::render_picker(frame, area, picker, app.theme); + } } } @@ -211,7 +215,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 +240,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 @@ -258,21 +262,17 @@ fn render_help_popup(frame: &mut Frame, app: &mut App, m: &catppuccin::FlavorCol .block( Block::default() .title(" Help — j/k to scroll · ? or Esc to close ") - .title_style( - Style::default() - .fg(c(m.lavender)) - .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.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)]; @@ -373,8 +373,8 @@ fn shortcut_bar<'a>(app: &App, m: &catppuccin::FlavorColors) -> Line<'a> { &[ ("← →", "Navigate"), ("Space", "Toggle Y"), - ("Enter", "Pick X"), - ("i", "Use row index as X"), + ("Enter", "Pick X axis"), + ("i", "Plot with index"), ("Esc", "Cancel"), ], &[], @@ -385,6 +385,10 @@ fn shortcut_bar<'a>(app: &App, m: &catppuccin::FlavorColors) -> 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 { ( @@ -434,16 +438,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 +467,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() { @@ -481,12 +485,12 @@ fn get_bar(app: &App, m: &catppuccin::FlavorColors) -> (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() - .bg(c(m.mauve)) - .fg(c(m.base)) + .bg(theme.info) + .fg(theme.bg) .add_modifier(Modifier::BOLD), ) } @@ -504,13 +508,23 @@ 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), ) } // 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 @@ -530,8 +544,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 +555,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 +571,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 +620,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 +632,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 +642,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 +666,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 +701,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 +720,28 @@ 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)) - .add_modifier(Modifier::BOLD), + Style::default().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![ @@ -767,13 +779,23 @@ fn help_text(m: &catppuccin::FlavorColors) -> 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)"), @@ -786,7 +808,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 +830,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,35 +848,27 @@ 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]); // Values table let header = Row::new([ - Cell::from("Value").style( - Style::default() - .fg(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Count").style( - Style::default() - .fg(c(m.lavender)) - .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(c(m.surface0))) + .style(Style::default().bg(theme.bg_alt)) .bottom_margin(1); let rows: Vec = app @@ -863,10 +877,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,16 +890,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) { - 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() @@ -900,72 +913,36 @@ 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)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Type").style( - Style::default() - .fg(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Count").style( - Style::default() - .fg(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Nulls").style( - Style::default() - .fg(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Unique").style( - Style::default() - .fg(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Min").style( - Style::default() - .fg(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Max").style( - Style::default() - .fg(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Mean").style( - Style::default() - .fg(c(m.lavender)) - .add_modifier(Modifier::BOLD), - ), - Cell::from("Median").style( - Style::default() - .fg(c(m.lavender)) - .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(c(m.surface0))) + .style(Style::default().bg(theme.bg_alt)) .bottom_margin(1); let rows: Vec = app @@ -977,7 +954,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 +982,51 @@ 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)> { @@ -1109,13 +1081,7 @@ fn compute_histogram(app: &App, y_idx: usize) -> Result, String> .collect()) } -fn render_histogram( - frame: &mut Frame, - app: &App, - m: &catppuccin::FlavorColors, - 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)]) @@ -1126,7 +1092,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 +1103,16 @@ 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 +1135,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,8 +1170,7 @@ fn render_histogram( frame.render_widget(chart, chart_area); } -fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { - 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() { @@ -1210,7 +1179,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 +1247,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 +1256,16 @@ 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 +1301,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 +1329,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 +1350,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 +1359,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,17 +1370,12 @@ fn render_plot(frame: &mut Frame, app: &App, m: &catppuccin::FlavorColors) { chart_area, label_area, y_label_width, - c(m.subtext1), + theme.fg_dim, ); } } -fn render_plot_legend( - frame: &mut Frame, - app: &App, - m: &catppuccin::FlavorColors, - 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 @@ -1443,8 +1411,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 +1421,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 +1684,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 +1706,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 +1719,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 +1741,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()); + 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 +1815,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 +1826,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 +1837,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 +1908,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 +1917,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 +1942,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 +1970,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();