├── docs ├── CNAME ├── assets │ ├── hero.png │ ├── feature10.png │ ├── feature11.png │ ├── feature12.png │ ├── feature13.png │ ├── feature14.png │ ├── feature3.png │ ├── feature6.png │ ├── feature7.png │ ├── feature8.png │ └── feature-dataframe-details.png ├── styles.css └── index.html ├── src ├── tabview.rs ├── dataframe │ ├── meta.rs │ └── mod.rs ├── lib.rs ├── jmes │ ├── runtime.rs │ ├── mod.rs │ └── functions.rs ├── dialog │ ├── llm │ │ └── mod.rs │ ├── styling │ │ ├── mod.rs │ │ ├── style_set_browser_dialog.rs │ │ └── style_set_manager.rs │ ├── mod.rs │ ├── error_dialog.rs │ ├── keybinding_capture_dialog.rs │ ├── message_dialog.rs │ ├── alias_edit_dialog.rs │ └── find_all_results_dialog.rs ├── cli.rs ├── components │ ├── home.rs │ ├── dialog_layout.rs │ ├── fps.rs │ └── mod.rs ├── logging.rs ├── errors.rs ├── bin │ └── style_schema.rs ├── excel_operations.rs ├── update_check.rs ├── data_import_types.rs ├── style.rs ├── sql │ └── mod.rs ├── tui.rs ├── workspace.rs └── action.rs ├── .gitignore ├── CHANGELOG.md ├── .vscode └── settings.json ├── sample-data └── rule-set-example.rs.yml ├── tests └── style_set_roundtrip.rs ├── .github └── workflows │ ├── azure-static-web-apps-blue-stone-00ea39a10.yml │ └── release.yml ├── Cargo.toml ├── README.md ├── .config └── config.json5 └── LICENSE /docs/CNAME: -------------------------------------------------------------------------------- 1 | www.datatui.io -------------------------------------------------------------------------------- /src/tabview.rs: -------------------------------------------------------------------------------- 1 | // TabView component stub -------------------------------------------------------------------------------- /src/dataframe/meta.rs: -------------------------------------------------------------------------------- 1 | // DataFrameMeta struct stub -------------------------------------------------------------------------------- /src/dataframe/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod manager; 2 | pub mod meta; -------------------------------------------------------------------------------- /docs/assets/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/hero.png -------------------------------------------------------------------------------- /docs/assets/feature10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature10.png -------------------------------------------------------------------------------- /docs/assets/feature11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature11.png -------------------------------------------------------------------------------- /docs/assets/feature12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature12.png -------------------------------------------------------------------------------- /docs/assets/feature13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature13.png -------------------------------------------------------------------------------- /docs/assets/feature14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature14.png -------------------------------------------------------------------------------- /docs/assets/feature3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature3.png -------------------------------------------------------------------------------- /docs/assets/feature6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature6.png -------------------------------------------------------------------------------- /docs/assets/feature7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature7.png -------------------------------------------------------------------------------- /docs/assets/feature8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature8.png -------------------------------------------------------------------------------- /docs/assets/feature-dataframe-details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forensicmatt/datatui/HEAD/docs/assets/feature-dataframe-details.png -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::collapsible_if)] 2 | #![allow(clippy::collapsible_match)] 3 | #![allow(clippy::collapsible_else_if)] 4 | pub mod style; 5 | pub mod components; 6 | pub mod dataframe; 7 | pub mod action; 8 | pub mod config; 9 | pub mod tui; 10 | pub mod dialog; 11 | pub mod tabview; 12 | pub mod data_import_types; 13 | pub mod excel_operations; 14 | pub mod jmes; 15 | pub mod workspace; 16 | pub mod logging; 17 | pub mod sql; 18 | pub mod update_check; -------------------------------------------------------------------------------- /src/jmes/runtime.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use jmespath::Runtime; 3 | use super::register_custom_functions; 4 | 5 | thread_local! { 6 | static RUNTIME: RefCell = RefCell::new({ 7 | let mut rt = Runtime::new(); 8 | rt.register_builtin_functions(); 9 | register_custom_functions(&mut rt); 10 | rt 11 | }); 12 | } 13 | 14 | /// Execute a closure with a thread-local Runtime instance. 15 | pub fn with_runtime(f: impl FnOnce(&mut Runtime) -> R) -> R { 16 | RUNTIME.with(|cell| { 17 | let mut rt = cell.borrow_mut(); 18 | f(&mut rt) 19 | }) 20 | } 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/dialog/llm/mod.rs: -------------------------------------------------------------------------------- 1 | //! LLM Provider Configuration Dialogs 2 | //! 3 | //! This module contains individual dialogs for configuring each LLM provider. 4 | //! Each dialog is responsible for editing the specific configuration for its provider type. 5 | 6 | pub mod azure_openai; 7 | pub mod openai; 8 | pub mod ollama; 9 | 10 | pub use azure_openai::{AzureOpenAiConfigDialog, AzureOpenAiConfig}; 11 | pub use openai::{OpenAiConfigDialog, OpenAIConfig}; 12 | pub use ollama::{OllamaConfigDialog, OllamaConfig}; 13 | 14 | 15 | pub trait LlmConfig { 16 | /// Returns true if the configuration is considered valid & complete, otherwise false. 17 | fn is_configured(&self) -> bool; 18 | } 19 | -------------------------------------------------------------------------------- /src/jmes/mod.rs: -------------------------------------------------------------------------------- 1 | //! Custom JMESPath runtime and functions 2 | //! 3 | //! This module exposes a reusable `Runtime` preloaded with built-in functions 4 | //! and application-specific custom functions. 5 | 6 | mod runtime; 7 | mod functions; 8 | 9 | pub use runtime::with_runtime; 10 | pub use functions::register_custom_functions; 11 | 12 | /// Create a new `Runtime` with built-in functions and our custom functions registered. 13 | // Helper kept for callers that want a fresh instance rather than thread-local. 14 | pub fn new_runtime() -> jmespath::Runtime { 15 | let mut rt = jmespath::Runtime::new(); 16 | rt.register_builtin_functions(); 17 | register_custom_functions(&mut rt); 18 | rt 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug 4 | target 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | # Generated by cargo mutants 13 | # Contains mutation testing data 14 | **/mutants.out*/ 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | .idea/ 22 | .workspaces 23 | # Environment variables 24 | .env 25 | # Editor directories and files 26 | .idea 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | *.sw? 32 | .DS_Store 33 | *.log -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to 7 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [0.4.0] 10 | ### Added 11 | - Add update check 12 | - Added highlight rule sets 13 | - Template validation tool for rule sets 14 | 15 | ### Fixed 16 | - Various dialog navigation issues 17 | - Double loading data after import 18 | 19 | ## [0.3.0] 20 | ### Added 21 | - Prompt similarity sorting to Column Operations 22 | 23 | ## [0.2.0] 24 | ### Added 25 | - cargo.lock (removed cargo.lock from .gitignore) 26 | - customizable and persistent key bindings 27 | - parameters for loading data with the cli 28 | 29 | ## [0.1.1] 30 | ### Added 31 | - added ctrl + a (select all) and ctrl + c (copy) to SqlDialog 32 | 33 | ### Fixed 34 | - Instruction rendering issues 35 | - FindAllResults scroll issues 36 | - Temporarily fixed highlights of text in Spans by stripping out new line chars. 37 | 38 | 39 | ## [0.1.0] 40 | ### Added 41 | - Initial Version 42 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "yaml.schemas": { 3 | "./sample-data/style-set-schema.json": [ 4 | "*.rs.yml", 5 | "**/*.rs.yml" 6 | ] 7 | }, 8 | "json.schemas": [ 9 | { 10 | "fileMatch": [ 11 | "*.rs.yml", 12 | "**/*.rs.yml" 13 | ], 14 | "url": "./sample-data/style-set-schema.json" 15 | } 16 | ], 17 | "yaml.customTags": [ 18 | "!Conditional mapping", 19 | "!Gradient mapping", 20 | "!Categorical mapping", 21 | "!Filter mapping", 22 | "!Regex mapping", 23 | "!Condition mapping", 24 | "!And sequence", 25 | "!Or sequence", 26 | "!Contains mapping", 27 | "!Equals mapping", 28 | "!GreaterThan mapping", 29 | "!LessThan mapping", 30 | "!GreaterThanOrEqual mapping", 31 | "!LessThanOrEqual mapping", 32 | "!Between mapping", 33 | "!InList mapping", 34 | "!Not mapping", 35 | "!CompareColumns mapping", 36 | "!StringLength mapping", 37 | "!IsEmpty scalar", 38 | "!IsNotEmpty scalar", 39 | "!NotNull scalar", 40 | "!IsNull scalar", 41 | "!RegexGroup mapping", 42 | "!Name scalar", 43 | "!Group scalar" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/dialog/styling/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod style_set; 2 | pub mod style_set_manager; 3 | pub mod style_set_manager_dialog; 4 | pub mod style_rule_editor_dialog; 5 | pub mod style_set_browser_dialog; 6 | pub mod style_set_editor_dialog; 7 | pub mod application_scope_editor_dialog; 8 | pub mod color_picker_dialog; 9 | pub mod templates; 10 | 11 | pub use style_set::{ 12 | StyleSet, StyleRule, MatchedStyle, 13 | GrepCapture, ApplicationScope, StyleApplication, 14 | Condition, ConditionalStyle, StyleLogic, 15 | MergeMode, GradientStyle, GradientScale, CategoricalStyle, 16 | SchemaHint, ColumnMatcher, ExpectedType, 17 | matches_column, 18 | }; 19 | pub use templates::{get_all_templates, get_template_categories, create_template_styleset, TemplateCategory}; 20 | pub use style_set_manager::StyleSetManager; 21 | pub use style_set_manager_dialog::StyleSetManagerDialog; 22 | pub use style_rule_editor_dialog::StyleRuleEditorDialog; 23 | pub use style_set_browser_dialog::StyleSetBrowserDialog; 24 | pub use style_set_editor_dialog::StyleSetEditorDialog; 25 | pub use application_scope_editor_dialog::ApplicationScopeEditorDialog; 26 | pub use color_picker_dialog::ColorPickerDialog; 27 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | use crate::config::{get_config_dir, get_data_dir}; 4 | 5 | #[derive(Parser, Debug)] 6 | #[command(author, version = version(), about)] 7 | pub struct Cli { 8 | /// Tick rate, i.e. number of ticks per second 9 | #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)] 10 | pub tick_rate: f64, 11 | 12 | /// Frame rate, i.e. number of frames per second 13 | #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)] 14 | pub frame_rate: f64, 15 | } 16 | 17 | const VERSION_MESSAGE: &str = concat!( 18 | env!("CARGO_PKG_VERSION"), 19 | "-", 20 | env!("VERGEN_GIT_DESCRIBE"), 21 | " (", 22 | env!("VERGEN_BUILD_DATE"), 23 | ")" 24 | ); 25 | 26 | pub fn version() -> String { 27 | let author = clap::crate_authors!(); 28 | 29 | // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); 30 | let config_dir_path = get_config_dir().display().to_string(); 31 | let data_dir_path = get_data_dir().display().to_string(); 32 | 33 | format!( 34 | "\ 35 | {VERSION_MESSAGE} 36 | 37 | Authors: {author} 38 | 39 | Config directory: {config_dir_path} 40 | Data directory: {data_dir_path}" 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /sample-data/rule-set-example.rs.yml: -------------------------------------------------------------------------------- 1 | id: 72fcd924-38df-4965-8014-1cdc0e4f13c3 2 | name: Style Set Name 3 | categories: 4 | - Rockstar 5 | tags: 6 | - Test 7 | description: A test template 8 | rules: 9 | - name: Rule1 10 | logic: 11 | Conditional: 12 | condition: 13 | Filter: 14 | expr: 15 | And: 16 | - Condition: 17 | column: boardgame 18 | condition: 19 | Contains: 20 | value: oo 21 | case_sensitive: false 22 | applications: 23 | - scope: Row 24 | style: 25 | fg: Yellow 26 | priority: 0 27 | merge_mode: Override 28 | - name: oo Rule 29 | logic: 30 | Conditional: 31 | condition: 32 | Filter: 33 | expr: 34 | And: 35 | - Condition: 36 | column: boardgame 37 | condition: 38 | Contains: 39 | value: oo 40 | case_sensitive: false 41 | columns: 42 | - boardgame 43 | applications: 44 | - scope: Cell 45 | style: 46 | bg: Red 47 | modifiers: 48 | - Bold 49 | priority: 0 50 | merge_mode: Additive 51 | -------------------------------------------------------------------------------- /src/components/home.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use ratatui::{prelude::*, widgets::*}; 3 | use tokio::sync::mpsc::UnboundedSender; 4 | 5 | use super::Component; 6 | use crate::{action::Action, config::Config}; 7 | 8 | #[derive(Default)] 9 | pub struct Home { 10 | command_tx: Option>, 11 | config: Config, 12 | } 13 | 14 | impl Home { 15 | pub fn new() -> Self { 16 | Self::default() 17 | } 18 | } 19 | 20 | impl Component for Home { 21 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 22 | self.command_tx = Some(tx); 23 | Ok(()) 24 | } 25 | 26 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 27 | self.config = config; 28 | Ok(()) 29 | } 30 | 31 | fn update(&mut self, action: Action) -> Result> { 32 | match action { 33 | Action::Tick => { 34 | // add any logic here that should run on every tick 35 | } 36 | Action::Render => { 37 | // add any logic here that should run on every render 38 | } 39 | _ => {} 40 | } 41 | Ok(None) 42 | } 43 | 44 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 45 | frame.render_widget(Paragraph::new("hello world"), area); 46 | Ok(()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/style_set_roundtrip.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::PathBuf; 3 | 4 | use datatui::dialog::StyleSet; 5 | 6 | fn sample_path() -> PathBuf { 7 | PathBuf::from(env!("CARGO_MANIFEST_DIR")) 8 | .join("sample-data") 9 | .join("rule-set-example.yml") 10 | } 11 | 12 | #[test] 13 | fn deserialize_sample_style_set() { 14 | let path = sample_path(); 15 | let yaml = fs::read_to_string(&path) 16 | .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); 17 | 18 | let style_set: StyleSet = 19 | serde_yaml::from_str(&yaml).expect("sample rule-set-example.yml should deserialize"); 20 | 21 | assert_eq!(style_set.name, "Style Set Name"); 22 | assert!(!style_set.rules.is_empty()); 23 | assert!(style_set.description.contains("test")); 24 | } 25 | 26 | #[test] 27 | fn serialize_roundtrip_preserves_style_set() { 28 | let path = sample_path(); 29 | let yaml = fs::read_to_string(&path) 30 | .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); 31 | 32 | let style_set: StyleSet = 33 | serde_yaml::from_str(&yaml).expect("sample rule-set-example.yml should deserialize"); 34 | 35 | let serialized = serde_yaml::to_string(&style_set).expect("serialize StyleSet"); 36 | let decoded: StyleSet = 37 | serde_yaml::from_str(&serialized).expect("deserialize serialized StyleSet"); 38 | 39 | assert_eq!(style_set, decoded); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/dialog_layout.rs: -------------------------------------------------------------------------------- 1 | use ratatui::prelude::Rect; 2 | 3 | /// Helper for dialog layout with optional instructions area 4 | pub struct DialogLayout { 5 | pub content_area: Rect, 6 | pub instructions_area: Option, 7 | } 8 | impl DialogLayout { 9 | pub fn new(content_area: Rect, instructions_area: Option) -> Self { 10 | Self { content_area, instructions_area } 11 | } 12 | 13 | pub fn get_total_area(&self) -> Rect { 14 | let mut total_area = self.content_area; 15 | if let Some(instructions_area) = self.instructions_area { 16 | total_area.height += instructions_area.height; 17 | } 18 | total_area 19 | } 20 | } 21 | 22 | pub fn split_dialog_area( 23 | area: Rect, 24 | show_instructions: bool, 25 | instructions: Option<&str>, 26 | ) -> DialogLayout { 27 | if show_instructions { 28 | let wrap_width = area.width.saturating_sub(4).max(10) as usize; 29 | let instructions = instructions.unwrap_or(""); 30 | let wrapped_lines = textwrap::wrap(instructions, wrap_width); 31 | let instructions_height = (wrapped_lines.len() as u16).max(1) + 2; 32 | let content_area = Rect { 33 | x: area.x, 34 | y: area.y, 35 | width: area.width, 36 | height: area.height.saturating_sub(instructions_height), 37 | }; 38 | let instructions_area = Rect { 39 | x: area.x, 40 | y: area.y + area.height.saturating_sub(instructions_height), 41 | width: area.width, 42 | height: instructions_height, 43 | }; 44 | DialogLayout { 45 | content_area, 46 | instructions_area: Some(instructions_area), 47 | } 48 | } else { 49 | DialogLayout { 50 | content_area: area, 51 | instructions_area: None, 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /.github/workflows/azure-static-web-apps-blue-stone-00ea39a10.yml: -------------------------------------------------------------------------------- 1 | name: Azure Static Web Apps CI/CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, closed] 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build_and_deploy_job: 14 | if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') 15 | runs-on: ubuntu-latest 16 | name: Build and Deploy Job 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | submodules: true 21 | lfs: false 22 | - name: Build And Deploy 23 | id: builddeploy 24 | uses: Azure/static-web-apps-deploy@v1 25 | with: 26 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BLUE_STONE_00EA39A10 }} 27 | repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) 28 | action: "upload" 29 | ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### 30 | # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig 31 | app_location: "./docs" # App source code path 32 | api_location: "" # Api source code path - optional 33 | output_location: "." # Built app content directory - optional 34 | ###### End of Repository/Build Configurations ###### 35 | 36 | close_pull_request_job: 37 | if: github.event_name == 'pull_request' && github.event.action == 'closed' 38 | runs-on: ubuntu-latest 39 | name: Close Pull Request Job 40 | steps: 41 | - name: Close Pull Request 42 | id: closepullrequest 43 | uses: Azure/static-web-apps-deploy@v1 44 | with: 45 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BLUE_STONE_00EA39A10 }} 46 | action: "close" 47 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use tracing_error::ErrorLayer; 3 | use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 4 | 5 | use crate::config; 6 | 7 | lazy_static::lazy_static! { 8 | pub static ref LOG_ENV: String = format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone()); 9 | pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); 10 | } 11 | 12 | pub fn init() -> Result<()> { init_with(None, None) } 13 | 14 | pub fn init_with(custom_log_path: Option, level: Option) -> Result<()> { 15 | // Determine log file path 16 | let log_path = if let Some(path) = custom_log_path { 17 | if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } 18 | path 19 | } else { 20 | let directory = config::get_data_dir(); 21 | std::fs::create_dir_all(directory.clone())?; 22 | directory.join(LOG_FILE.clone()) 23 | }; 24 | 25 | // Configure filter. CLI level overrides env; otherwise try env, else default INFO 26 | let env_filter = if let Some(lvl) = level { 27 | EnvFilter::builder() 28 | .with_default_directive(lvl.into()) 29 | .from_env_lossy() 30 | } else { 31 | let builder = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into()); 32 | builder 33 | .try_from_env() 34 | .or_else(|_| builder.with_env_var(LOG_ENV.clone()).from_env())? 35 | }; 36 | 37 | let writer_path = log_path.clone(); 38 | let file_subscriber = fmt::layer() 39 | .with_file(true) 40 | .with_line_number(true) 41 | .with_writer(move || { 42 | std::fs::OpenOptions::new() 43 | .create(true) 44 | .append(true) 45 | .open(&writer_path) 46 | .expect("failed to open log file") 47 | }) 48 | .with_target(false) 49 | .with_ansi(false) 50 | .with_filter(env_filter); 51 | tracing_subscriber::registry() 52 | .with(file_subscriber) 53 | .with(ErrorLayer::default()) 54 | .try_init()?; 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /src/components/fps.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | use color_eyre::Result; 4 | use ratatui::{ 5 | Frame, 6 | layout::{Constraint, Layout, Rect}, 7 | style::{Style, Stylize}, 8 | text::Span, 9 | widgets::Paragraph, 10 | }; 11 | 12 | use super::Component; 13 | 14 | use crate::action::Action; 15 | 16 | #[derive(Debug, Clone, PartialEq)] 17 | pub struct FpsCounter { 18 | last_tick_update: Instant, 19 | tick_count: u32, 20 | ticks_per_second: f64, 21 | 22 | last_frame_update: Instant, 23 | frame_count: u32, 24 | frames_per_second: f64, 25 | } 26 | 27 | impl Default for FpsCounter { 28 | fn default() -> Self { 29 | Self::new() 30 | } 31 | } 32 | 33 | impl FpsCounter { 34 | pub fn new() -> Self { 35 | Self { 36 | last_tick_update: Instant::now(), 37 | tick_count: 0, 38 | ticks_per_second: 0.0, 39 | last_frame_update: Instant::now(), 40 | frame_count: 0, 41 | frames_per_second: 0.0, 42 | } 43 | } 44 | 45 | fn app_tick(&mut self) -> Result<()> { 46 | self.tick_count += 1; 47 | let now = Instant::now(); 48 | let elapsed = (now - self.last_tick_update).as_secs_f64(); 49 | if elapsed >= 1.0 { 50 | self.ticks_per_second = self.tick_count as f64 / elapsed; 51 | self.last_tick_update = now; 52 | self.tick_count = 0; 53 | } 54 | Ok(()) 55 | } 56 | 57 | fn render_tick(&mut self) -> Result<()> { 58 | self.frame_count += 1; 59 | let now = Instant::now(); 60 | let elapsed = (now - self.last_frame_update).as_secs_f64(); 61 | if elapsed >= 1.0 { 62 | self.frames_per_second = self.frame_count as f64 / elapsed; 63 | self.last_frame_update = now; 64 | self.frame_count = 0; 65 | } 66 | Ok(()) 67 | } 68 | } 69 | 70 | impl Component for FpsCounter { 71 | fn update(&mut self, action: Action) -> Result> { 72 | match action { 73 | Action::Tick => self.app_tick()?, 74 | Action::Render => self.render_tick()?, 75 | _ => {} 76 | }; 77 | Ok(None) 78 | } 79 | 80 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 81 | let [top, _] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); 82 | let message = format!( 83 | "{:.2} ticks/sec, {:.2} FPS", 84 | self.ticks_per_second, self.frames_per_second 85 | ); 86 | let span = Span::styled(message, Style::new().dim()); 87 | let paragraph = Paragraph::new(span).right_aligned(); 88 | frame.render_widget(paragraph, top); 89 | Ok(()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "datatui" 3 | version = "0.4.0" 4 | edition = "2024" 5 | description = "DataTUI: A fast, keyboard-first terminal data viewer." 6 | authors = ["Matthew Seyer "] 7 | license = "Apache-2.0" 8 | readme = "README.md" 9 | 10 | [features] 11 | default = [] 12 | json_schema = ["schemars", "jsonschema"] 13 | 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [dependencies] 18 | anyhow = "1.0.90" 19 | arboard = "3.3.0" 20 | better-panic = "0.3.0" 21 | chrono = "0.4.41" 22 | clap = { version = "4.5.20", features = [ 23 | "derive", 24 | "cargo", 25 | "wrap_help", 26 | "unicode", 27 | "string", 28 | "unstable-styles", 29 | ] } 30 | color-eyre = "0.6.3" 31 | config = "0.14.0" 32 | crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } 33 | derive_deref = "1.1.1" 34 | directories = "5.0.1" 35 | futures = "0.3.31" 36 | human-panic = "2.0.2" 37 | json5 = "0.4.1" 38 | lazy_static = "1.5.0" 39 | libc = "0.2.161" 40 | polars = { version = "0.49.1", features = ["strings", "diff", "sql", "lazy", "parquet", "json"] } 41 | polars-lazy = "0.49.1" 42 | polars-sql = "0.49.1" 43 | polars-plan = "0.49.1" 44 | pretty_assertions = "1.4.1" 45 | ratatui = { version = "0.29.0", features = ["serde", "macros", "crossterm"] } 46 | regex = "1.11.1" 47 | serde = { version = "1.0.211", features = ["derive"] } 48 | serde_json = { version = "1.0.132", features = ["preserve_order"] } 49 | signal-hook = "0.3.17" 50 | strip-ansi-escapes = "0.2.0" 51 | strum = { version = "0.26.3", features = ["derive"] } 52 | textwrap = "0.16.2" 53 | tokio = { version = "1.40.0", features = ["full"] } 54 | tokio-util = "0.7.12" 55 | tracing = "0.1.40" 56 | tracing-error = "0.2.0" 57 | tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } 58 | tui-textarea = "0.7.0" 59 | calamine = "0.29.0" 60 | uuid = { version = "1.17.0", features = ["v4"] } 61 | jmespath = "0.3" 62 | thiserror = "1.0.65" 63 | rig-core = "0.23.1" 64 | reqwest = { version = "0.12.8", features = ["json", "blocking", "rustls-tls"] } 65 | linfa = "0.7.1" 66 | linfa-reduction = "0.7.1" 67 | ndarray = "0.15" 68 | linfa-clustering = "0.7.1" 69 | linfa-nn = "0.7.1" 70 | rusqlite = { version = "0.31.0", features = ["bundled"] } 71 | csv = "1.3.0" 72 | encoding_rs = "0.8.35" 73 | glob = "0.3.1" 74 | toml = "0.8.12" 75 | serde_yaml = "0.9" 76 | globset = "0.4" 77 | rayon = "1.8" 78 | schemars = { version = "0.8.21", optional = true, features = ["preserve_order"] } 79 | jsonschema = { version = "0.17.1", optional = true } 80 | 81 | [[bin]] 82 | name = "style-schema" 83 | path = "src/bin/style_schema.rs" 84 | required-features = ["json_schema"] 85 | 86 | [build-dependencies] 87 | anyhow = "1.0.90" 88 | vergen-gix = { version = "1.0.2", features = ["build", "cargo"] } 89 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use color_eyre::Result; 4 | use tracing::error; 5 | 6 | pub fn init() -> Result<()> { 7 | let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() 8 | .panic_section(format!( 9 | "This is a bug. Consider reporting it at {}", 10 | env!("CARGO_PKG_REPOSITORY") 11 | )) 12 | .capture_span_trace_by_default(false) 13 | .display_location_section(false) 14 | .display_env_section(false) 15 | .into_hooks(); 16 | eyre_hook.install()?; 17 | std::panic::set_hook(Box::new(move |panic_info| { 18 | if let Ok(mut t) = crate::tui::Tui::new() { 19 | if let Err(r) = t.exit() { 20 | error!("Unable to exit Terminal: {:?}", r); 21 | } 22 | } 23 | 24 | #[cfg(not(debug_assertions))] 25 | { 26 | use human_panic::{handle_dump, metadata, print_msg}; 27 | let metadata = metadata!(); 28 | let file_path = handle_dump(&metadata, panic_info); 29 | // prints human-panic message 30 | print_msg(file_path, &metadata) 31 | .expect("human-panic: printing error message to console failed"); 32 | eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr 33 | } 34 | let msg = format!("{}", panic_hook.panic_report(panic_info)); 35 | error!("Error: {}", strip_ansi_escapes::strip_str(msg)); 36 | 37 | #[cfg(debug_assertions)] 38 | { 39 | // Better Panic stacktrace that is only enabled when debugging. 40 | better_panic::Settings::auto() 41 | .most_recent_first(false) 42 | .lineno_suffix(true) 43 | .verbosity(better_panic::Verbosity::Full) 44 | .create_panic_handler()(panic_info); 45 | } 46 | 47 | std::process::exit(libc::EXIT_FAILURE); 48 | })); 49 | Ok(()) 50 | } 51 | 52 | /// Similar to the `std::dbg!` macro, but generates `tracing` events rather 53 | /// than printing to stdout. 54 | /// 55 | /// By default, the verbosity level for the generated events is `DEBUG`, but 56 | /// this can be customized. 57 | #[macro_export] 58 | macro_rules! trace_dbg { 59 | (target: $target:expr, level: $level:expr, $ex:expr) => { 60 | { 61 | match $ex { 62 | value => { 63 | tracing::event!(target: $target, $level, ?value, stringify!($ex)); 64 | value 65 | } 66 | } 67 | } 68 | }; 69 | (level: $level:expr, $ex:expr) => { 70 | trace_dbg!(target: module_path!(), level: $level, $ex) 71 | }; 72 | (target: $target:expr, $ex:expr) => { 73 | trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) 74 | }; 75 | ($ex:expr) => { 76 | trace_dbg!(level: tracing::Level::DEBUG, $ex) 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/bin/style_schema.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "json_schema")] 2 | 3 | use anyhow::{Context, Result}; 4 | use clap::{Parser, Subcommand}; 5 | use datatui::dialog::styling::style_set::StyleSet; 6 | use jsonschema::{Draft, JSONSchema}; 7 | use schemars::schema_for; 8 | use std::{fs, path::PathBuf}; 9 | 10 | /// Generate the JSON Schema for StyleSet or validate a YAML rule-set against it. 11 | #[derive(Parser, Debug)] 12 | #[command(name = "style-schema", about = "StyleSet schema generator and validator")] 13 | struct Cli { 14 | #[command(subcommand)] 15 | command: Command, 16 | } 17 | 18 | #[derive(Subcommand, Debug)] 19 | enum Command { 20 | /// Print the StyleSet JSON schema (or write it to a file) 21 | Schema { 22 | /// Optional output path for the schema JSON 23 | #[arg(short, long)] 24 | output: Option, 25 | }, 26 | /// Validate a YAML rule-set file against the StyleSet schema 27 | Validate { 28 | /// Path to the YAML file to validate 29 | file: PathBuf, 30 | }, 31 | } 32 | 33 | fn main() -> Result<()> { 34 | let cli = Cli::parse(); 35 | 36 | match cli.command { 37 | Command::Schema { output } => { 38 | let schema = schema_for!(StyleSet); 39 | let json = serde_json::to_string_pretty(&schema)?; 40 | 41 | if let Some(path) = output { 42 | fs::write(&path, json)?; 43 | eprintln!("Wrote schema to {}", path.display()); 44 | } else { 45 | println!("{json}"); 46 | } 47 | } 48 | Command::Validate { file } => { 49 | let schema = schema_for!(StyleSet); 50 | // jsonschema keeps a reference to the schema; leak a small boxed value to satisfy 'static. 51 | let schema_json = serde_json::to_value(schema)?; 52 | let schema_ref: &'static serde_json::Value = Box::leak(Box::new(schema_json)); 53 | let compiled = JSONSchema::options() 54 | .with_draft(Draft::Draft7) 55 | .compile(schema_ref) 56 | .context("failed to compile StyleSet schema")?; 57 | 58 | let yaml_text = fs::read_to_string(&file).context("failed to read YAML file")?; 59 | // First, deserialize into the Rust type so YAML tags (!Conditional, etc.) 60 | // are resolved to real enum variants. 61 | let styleset: StyleSet = serde_yaml::from_str(&yaml_text) 62 | .context("failed to deserialize YAML into StyleSet")?; 63 | // Re-serialize to JSON Value for schema validation 64 | let json_value = serde_json::to_value(styleset)?; 65 | 66 | if let Err(errors) = compiled.validate(&json_value) { 67 | eprintln!("Validation errors for {}:", file.display()); 68 | for err in errors { 69 | eprintln!("- {} at {}", err, err.instance_path); 70 | } 71 | std::process::exit(1); 72 | } else { 73 | println!("{} is a valid StyleSet YAML", file.display()); 74 | } 75 | } 76 | } 77 | 78 | Ok(()) 79 | } 80 | -------------------------------------------------------------------------------- /src/dialog/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod sort_dialog; 2 | pub mod filter_dialog; 3 | pub mod sql_dialog; 4 | pub mod column_width_dialog; 5 | pub mod file_browser_dialog; 6 | pub mod find_dialog; 7 | pub mod find_all_results_dialog; 8 | pub mod data_import_dialog; 9 | pub mod csv_options_dialog; 10 | pub mod xlsx_options_dialog; 11 | pub mod sqlite_options_dialog; 12 | pub mod parquet_options_dialog; 13 | pub mod json_options_dialog; 14 | pub mod data_management_dialog; 15 | pub mod data_tab_manager_dialog; 16 | pub mod alias_edit_dialog; 17 | pub mod project_settings_dialog; 18 | pub mod error_dialog; 19 | pub mod message_dialog; 20 | pub mod jmes_dialog; 21 | pub mod dataframe_details_dialog; 22 | pub mod table_export_dialog; 23 | pub mod data_export_dialog; 24 | pub mod keybinding_capture_dialog; 25 | pub mod column_operations_dialog; 26 | pub mod column_operation_options_dialog; 27 | pub mod keybindings_dialog; 28 | pub mod llm_client_dialog; 29 | pub mod llm_client_create_dialog; 30 | pub mod llm; 31 | pub mod embeddings_prompt_dialog; 32 | pub mod styling; 33 | pub use filter_dialog::{FilterCondition, ColumnFilter}; 34 | pub use column_width_dialog::ColumnWidthConfig; 35 | pub use find_dialog::{FindOptions, SearchMode}; 36 | pub use data_import_dialog::{DataImportDialog, FileType, DataImportDialogMode}; 37 | pub use csv_options_dialog::{CsvOptionsDialog, CsvImportOptions}; 38 | pub use xlsx_options_dialog::{XlsxOptionsDialog, XlsxImportOptions}; 39 | pub use sqlite_options_dialog::{SqliteOptionsDialog, SqliteImportOptions}; 40 | pub use parquet_options_dialog::{ParquetOptionsDialog, ParquetImportOptions}; 41 | pub use json_options_dialog::{JsonOptionsDialog, JsonImportOptions}; 42 | pub use data_management_dialog::{DataManagementDialog, DataSource, Dataset, DatasetStatus}; 43 | pub use data_tab_manager_dialog::{DataTabManagerDialog, DataTab}; 44 | pub use alias_edit_dialog::AliasEditDialog; 45 | pub use project_settings_dialog::{ProjectSettingsDialog, ProjectSettingsConfig}; 46 | pub use error_dialog::ErrorDialog; 47 | pub use message_dialog::MessageDialog; 48 | pub use jmes_dialog::JmesPathDialog; 49 | 50 | use serde::{Deserialize, Serialize}; 51 | use strum::Display; 52 | 53 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)] 54 | pub enum TransformScope { 55 | Original, 56 | Current, 57 | } 58 | pub use dataframe_details_dialog::DataFrameDetailsDialog; 59 | pub use table_export_dialog::TableExportDialog; 60 | pub use data_export_dialog::{DataExportDialog, DataExportFormat}; 61 | pub use column_operations_dialog::{ColumnOperationsDialog, ColumnOperationsMode, ColumnOperationKind}; 62 | pub use column_operation_options_dialog::{ColumnOperationOptionsDialog, ColumnOperationOptionsMode, ColumnOperationConfig, ClusterAlgorithm, KmeansOptions, DbscanOptions, OperationOptions}; 63 | pub use keybindings_dialog::KeybindingsDialog; 64 | pub use keybinding_capture_dialog::KeybindingCaptureDialog; 65 | pub use llm_client_dialog::{LlmClientDialog, LlmProvider, LlmConfig}; 66 | pub use llm_client_create_dialog::{LlmClientCreateDialog, LlmClientCreateMode, LlmClientSelection}; 67 | pub use llm::{AzureOpenAiConfig, OpenAIConfig, OllamaConfig}; 68 | pub use embeddings_prompt_dialog::EmbeddingsPromptDialog; 69 | pub use styling::{ 70 | StyleSet, StyleRule, MatchedStyle, 71 | ApplicationScope, StyleApplication, StyleLogic, Condition, ConditionalStyle, 72 | GradientStyle, CategoricalStyle, GrepCapture, 73 | StyleSetManager, StyleSetManagerDialog, StyleRuleEditorDialog, 74 | StyleSetBrowserDialog, StyleSetEditorDialog, ApplicationScopeEditorDialog, ColorPickerDialog 75 | }; 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | name: build-release 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | include: 19 | - build: linux 20 | os: ubuntu-latest 21 | rust: nightly 22 | target: x86_64-unknown-linux-musl 23 | strip: x86_64-linux-musl-strip 24 | - build: macos 25 | os: macos-latest 26 | rust: nightly 27 | target: x86_64-apple-darwin 28 | - build: win-msvc 29 | os: windows-latest 30 | rust: nightly 31 | target: x86_64-pc-windows-msvc 32 | - build: win-gnu 33 | os: windows-latest 34 | rust: nightly-x86_64-gnu 35 | target: x86_64-pc-windows-gnu 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v4 40 | 41 | - name: Install Rust 42 | uses: dtolnay/rust-toolchain@master 43 | with: 44 | toolchain: ${{ matrix.rust }} 45 | target: ${{ matrix.target }} 46 | 47 | - name: Build release binary 48 | run: cargo build --release 49 | 50 | - name: Package artifact (Linux/macOS) 51 | if: runner.os != 'Windows' 52 | shell: bash 53 | run: | 54 | set -euxo pipefail 55 | BIN_NAME="datatui" 56 | TAG_NAME="${GITHUB_REF_NAME}" 57 | OS="${RUNNER_OS}" 58 | ARCH="${RUNNER_ARCH}" 59 | ARTIFACT="datatui-${TAG_NAME}-${OS}-${ARCH}.tar.gz" 60 | mkdir -p dist 61 | cp "target/release/${BIN_NAME}" dist/ 62 | [ -f README.md ] && cp README.md dist/ || true 63 | [ -f LICENSE ] && cp LICENSE dist/ || true 64 | tar -C dist -czf "${ARTIFACT}" . 65 | echo "ARTIFACT=${ARTIFACT}" >> $GITHUB_ENV 66 | 67 | - name: Package artifact (Windows) 68 | if: runner.os == 'Windows' 69 | shell: pwsh 70 | run: | 71 | $ErrorActionPreference = "Stop" 72 | $BinName = "datatui.exe" 73 | $TagName = "${env:GITHUB_REF_NAME}" 74 | $Target = "${{ matrix.target }}" 75 | $Artifact = "datatui-$TagName-$Target.zip" 76 | New-Item -ItemType Directory -Force -Path dist | Out-Null 77 | Copy-Item "target\release\datatui.exe" "dist\" 78 | if (Test-Path "README.md") { Copy-Item "README.md" "dist\" } 79 | if (Test-Path "LICENSE") { Copy-Item "LICENSE" "dist\" } 80 | Compress-Archive -Path "dist\*" -DestinationPath $Artifact 81 | Add-Content -Path $env:GITHUB_ENV -Value "ARTIFACT=$Artifact" 82 | 83 | - name: Upload artifact 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: ${{ env.ARTIFACT }} 87 | path: ${{ env.ARTIFACT }} 88 | if-no-files-found: error 89 | 90 | release: 91 | name: Create GitHub Release 92 | needs: build 93 | runs-on: ubuntu-latest 94 | steps: 95 | - name: Download artifacts 96 | uses: actions/download-artifact@v4 97 | with: 98 | path: dist 99 | merge-multiple: true 100 | 101 | - name: Publish Release 102 | uses: softprops/action-gh-release@v2 103 | if: startsWith(github.ref, 'refs/tags/') 104 | with: 105 | files: dist/* 106 | draft: false 107 | prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} 108 | env: 109 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 110 | 111 | - name: release-plz 112 | uses: release-plz/action@v0.5.117 113 | env: 114 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 115 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 116 | -------------------------------------------------------------------------------- /src/dialog/error_dialog.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; 3 | use ratatui::prelude::*; 4 | use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; 5 | 6 | use crate::action::Action; 7 | use crate::components::Component; 8 | 9 | /// Simple reusable dialog for displaying error messages. 10 | #[derive(Debug, Clone)] 11 | pub struct ErrorDialog { 12 | message: String, 13 | title: String, 14 | } 15 | 16 | impl ErrorDialog { 17 | pub fn new(message: impl Into) -> Self { 18 | Self { 19 | message: message.into(), 20 | title: "Error".to_string(), 21 | } 22 | } 23 | 24 | pub fn with_title(message: impl Into, title: impl Into) -> Self { 25 | Self { 26 | message: message.into(), 27 | title: title.into(), 28 | } 29 | } 30 | 31 | pub fn set_message(&mut self, message: impl Into) { 32 | self.message = message.into(); 33 | } 34 | 35 | fn modal_area(&self, area: Rect) -> Rect { 36 | let max_width = area.width.saturating_sub(10).clamp(20, 80); 37 | let wrap_width = max_width.saturating_sub(4) as usize; 38 | let wrapped = textwrap::wrap(&self.message, wrap_width); 39 | let content_lines = wrapped.len() as u16; 40 | let height = content_lines 41 | .saturating_add(4) // top/bottom padding + hint line 42 | .clamp(5, area.height.saturating_sub(4)); 43 | 44 | let width = max_width; 45 | let x = area.x + (area.width.saturating_sub(width)) / 2; 46 | let y = area.y + (area.height.saturating_sub(height)) / 2; 47 | Rect { x, y, width, height } 48 | } 49 | 50 | pub fn render(&self, area: Rect, buf: &mut Buffer) { 51 | Clear.render(area, buf); 52 | let modal = self.modal_area(area); 53 | 54 | // Opaque background fill inside modal region 55 | for y in modal.y..modal.y + modal.height { 56 | let line = " ".repeat(modal.width as usize); 57 | buf.set_string(modal.x, y, &line, Style::default().bg(Color::Black)); 58 | } 59 | 60 | let block = Block::default() 61 | .title(self.title.as_str()) 62 | .borders(Borders::ALL) 63 | .border_style(Style::default().fg(Color::Red)) 64 | .style(Style::default().bg(Color::Black)); 65 | let inner = block.inner(modal); 66 | block.render(modal, buf); 67 | 68 | let hint = "Press Enter or Esc to close"; 69 | let wrap = Paragraph::new(self.message.as_str()) 70 | .wrap(Wrap { trim: false }) 71 | .style(Style::default().fg(Color::Red).bg(Color::Black)); 72 | wrap.render(inner, buf); 73 | 74 | if inner.height >= 2 { 75 | let hint_y = inner.y + inner.height - 1; 76 | let hint_x = inner.x + 1; 77 | buf.set_string(hint_x, hint_y, hint, Style::default().fg(Color::Gray).bg(Color::Black)); 78 | } 79 | } 80 | } 81 | 82 | /// Helper to render an `ErrorDialog` directly into a buffer within a given area. 83 | pub fn render_error_dialog(dialog: &ErrorDialog, area: Rect, buf: &mut Buffer) { 84 | dialog.render(area, buf); 85 | } 86 | 87 | impl Component for ErrorDialog { 88 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 89 | if key.kind == KeyEventKind::Press { 90 | match key.code { 91 | KeyCode::Enter | KeyCode::Esc => return Ok(Some(Action::DialogClose)), 92 | _ => {} 93 | } 94 | } 95 | Ok(None) 96 | } 97 | 98 | fn draw(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> { 99 | self.render(area, frame.buffer_mut()); 100 | Ok(()) 101 | } 102 | } 103 | 104 | 105 | -------------------------------------------------------------------------------- /src/excel_operations.rs: -------------------------------------------------------------------------------- 1 | //! Excel operations using the calamine library 2 | 3 | use calamine::{open_workbook_auto, Reader, Data}; 4 | use std::path::Path; 5 | use color_eyre::Result; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | /// Represents a worksheet in an Excel file 9 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 10 | pub struct WorksheetInfo { 11 | pub name: String, 12 | pub load: bool, 13 | pub row_count: usize, 14 | pub column_count: usize, 15 | pub non_empty_cells: usize, 16 | } 17 | 18 | impl WorksheetInfo { 19 | pub fn new(name: String) -> Self { 20 | Self { 21 | name, 22 | load: true, // Default to loading 23 | row_count: 0, 24 | column_count: 0, 25 | non_empty_cells: 0, 26 | } 27 | } 28 | } 29 | 30 | /// Excel operations struct for reading Excel files 31 | pub struct ExcelOperations; 32 | 33 | impl ExcelOperations { 34 | /// Read an Excel file and extract worksheet information 35 | pub fn read_worksheet_info(file_path: &Path) -> Result> { 36 | let mut workbook = open_workbook_auto(file_path)?; 37 | let mut worksheets = Vec::new(); 38 | 39 | // Get all sheet names 40 | let sheet_names = workbook.sheet_names().to_owned(); 41 | 42 | for sheet_name in sheet_names { 43 | let mut worksheet_info = WorksheetInfo::new(sheet_name.clone()); 44 | 45 | // Try to get the worksheet range 46 | if let Ok(range) = workbook.worksheet_range(&sheet_name) { 47 | let (rows, cols) = range.get_size(); 48 | worksheet_info.row_count = rows; 49 | worksheet_info.column_count = cols; 50 | 51 | // Count non-empty cells 52 | let non_empty_cells = range.used_cells().count(); 53 | worksheet_info.non_empty_cells = non_empty_cells; 54 | } 55 | 56 | worksheets.push(worksheet_info); 57 | } 58 | 59 | Ok(worksheets) 60 | } 61 | 62 | /// Get a preview of worksheet data (first few rows) 63 | pub fn get_worksheet_preview(file_path: &Path, sheet_name: &str, max_rows: usize) -> Result>> { 64 | let mut workbook = open_workbook_auto(file_path)?; 65 | let mut preview = Vec::new(); 66 | 67 | if let Ok(range) = workbook.worksheet_range(sheet_name) { 68 | for (row_idx, row) in range.rows().enumerate() { 69 | if row_idx >= max_rows { 70 | break; 71 | } 72 | 73 | let mut preview_row = Vec::new(); 74 | for cell in row { 75 | let cell_value = match cell { 76 | Data::Empty => String::new(), 77 | Data::String(s) => s.clone(), 78 | Data::Int(i) => i.to_string(), 79 | Data::Float(f) => f.to_string(), 80 | Data::Bool(b) => b.to_string(), 81 | Data::DateTime(d) => d.as_f64().to_string(), 82 | Data::DateTimeIso(s) => s.clone(), 83 | Data::DurationIso(s) => s.clone(), 84 | Data::Error(e) => format!("ERROR: {e:?}"), 85 | }; 86 | preview_row.push(cell_value); 87 | } 88 | preview.push(preview_row); 89 | } 90 | } 91 | 92 | Ok(preview) 93 | } 94 | 95 | /// Check if a file is a valid Excel file 96 | pub fn is_valid_excel_file(file_path: &Path) -> bool { 97 | if let Some(extension) = file_path.extension() && let Some(ext_str) = extension.to_str() { 98 | return matches!(ext_str.to_lowercase().as_str(), "xlsx" | "xlsm" | "xlsb" | "xls"); 99 | } 100 | false 101 | } 102 | } -------------------------------------------------------------------------------- /src/update_check.rs: -------------------------------------------------------------------------------- 1 | //! Update check functionality for checking GitHub releases 2 | 3 | use color_eyre::Result; 4 | use chrono::{DateTime, Utc, Duration}; 5 | use serde::Deserialize; 6 | 7 | const GITHUB_RELEASES_API: &str = "https://api.github.com/repos/forensicmatt/datatui/releases/latest"; 8 | 9 | #[derive(Debug, Deserialize)] 10 | struct GitHubRelease { 11 | tag_name: String, 12 | html_url: String, 13 | published_at: String, 14 | } 15 | 16 | /// Check if an update is available by comparing current version with latest GitHub release (async) 17 | pub async fn check_for_update_async(current_version: &str) -> Result> { 18 | let client = reqwest::Client::builder() 19 | .timeout(std::time::Duration::from_secs(5)) 20 | .user_agent("datatui-update-checker") 21 | .build()?; 22 | 23 | let response = client.get(GITHUB_RELEASES_API).send().await?; 24 | 25 | if !response.status().is_success() { 26 | return Ok(None); 27 | } 28 | 29 | let release: GitHubRelease = response.json().await?; 30 | 31 | // Extract version from tag_name (format: v0.3.0) 32 | let latest_version = release.tag_name.trim_start_matches('v'); 33 | let current_version_clean = current_version.trim_start_matches('v'); 34 | 35 | if is_newer_version(latest_version, current_version_clean) { 36 | Ok(Some(UpdateInfo { 37 | latest_version: release.tag_name.clone(), 38 | download_url: release.html_url, 39 | published_at: release.published_at, 40 | })) 41 | } else { 42 | Ok(None) 43 | } 44 | } 45 | 46 | /// Check if an update is available by comparing current version with latest GitHub release (blocking, for backwards compatibility) 47 | pub fn check_for_update(current_version: &str) -> Result> { 48 | let client = reqwest::blocking::Client::builder() 49 | .timeout(std::time::Duration::from_secs(5)) 50 | .user_agent("datatui-update-checker") 51 | .build()?; 52 | 53 | let response = client.get(GITHUB_RELEASES_API).send()?; 54 | 55 | if !response.status().is_success() { 56 | return Ok(None); 57 | } 58 | 59 | let release: GitHubRelease = response.json()?; 60 | 61 | // Extract version from tag_name (format: v0.3.0) 62 | let latest_version = release.tag_name.trim_start_matches('v'); 63 | let current_version_clean = current_version.trim_start_matches('v'); 64 | 65 | if is_newer_version(latest_version, current_version_clean) { 66 | Ok(Some(UpdateInfo { 67 | latest_version: release.tag_name.clone(), 68 | download_url: release.html_url, 69 | published_at: release.published_at, 70 | })) 71 | } else { 72 | Ok(None) 73 | } 74 | } 75 | 76 | /// Compare two version strings (format: x.y.z) 77 | /// Returns true if latest is newer than current 78 | fn is_newer_version(latest: &str, current: &str) -> bool { 79 | let parse_version = |v: &str| -> Option<(u32, u32, u32)> { 80 | let parts: Vec<&str> = v.split('.').collect(); 81 | if parts.len() >= 3 { 82 | Some(( 83 | parts[0].parse().ok()?, 84 | parts[1].parse().ok()?, 85 | parts[2].parse().ok()?, 86 | )) 87 | } else { 88 | None 89 | } 90 | }; 91 | 92 | let latest_parts = parse_version(latest); 93 | let current_parts = parse_version(current); 94 | 95 | match (latest_parts, current_parts) { 96 | (Some((l_maj, l_min, l_pat)), Some((c_maj, c_min, c_pat))) => { 97 | l_maj > c_maj 98 | || (l_maj == c_maj && l_min > c_min) 99 | || (l_maj == c_maj && l_min == c_min && l_pat > c_pat) 100 | } 101 | _ => false, 102 | } 103 | } 104 | 105 | #[derive(Debug, Clone)] 106 | pub struct UpdateInfo { 107 | pub latest_version: String, 108 | pub download_url: String, 109 | pub published_at: String, 110 | } 111 | 112 | /// Calculate the next update check date (1 day from now) 113 | pub fn calculate_next_check_date() -> DateTime { 114 | Utc::now() + Duration::days(1) 115 | } 116 | 117 | /// Check if it's time to check for updates based on the next_check_date 118 | pub fn should_check_for_updates(next_check_date: Option>) -> bool { 119 | match next_check_date { 120 | None => false, // Update checks disabled 121 | Some(date) => Utc::now() >= date, 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/dialog/keybinding_capture_dialog.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::{KeyEvent, KeyEventKind}; 3 | use ratatui::prelude::*; 4 | use ratatui::widgets::{Block, Borders, BorderType, Clear, Paragraph, Wrap}; 5 | 6 | use crate::action::Action; 7 | use crate::components::Component; 8 | use crate::config::{self, Config}; 9 | use crate::components::dialog_layout::split_dialog_area; 10 | 11 | #[derive(Debug, Default)] 12 | pub struct KeybindingCaptureDialog { 13 | pub show_instructions: bool, 14 | pub pressed_keys: Vec, 15 | pub pressed_display: String, 16 | pub config: Config, 17 | } 18 | 19 | impl KeybindingCaptureDialog { 20 | pub fn new() -> Self { 21 | Self { 22 | show_instructions: true, 23 | pressed_keys: Vec::new(), 24 | pressed_display: String::new(), 25 | config: Config::default(), 26 | } 27 | } 28 | 29 | fn build_instructions_from_config(&self) -> String { 30 | self.config.actions_to_instructions(&[ 31 | (crate::config::Mode::Global, Action::Enter), 32 | (crate::config::Mode::Global, Action::Escape), 33 | ]) 34 | } 35 | 36 | pub fn render(&self, area: Rect, buf: &mut Buffer) { 37 | // Compute a centered, smaller modal window inside the provided area 38 | let modal_width = area.width.saturating_sub(4).min(64).max(40); 39 | let modal_height = area.height.saturating_sub(4).min(10).max(7); 40 | let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2; 41 | let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2; 42 | let modal = Rect { x: modal_x, y: modal_y, width: modal_width, height: modal_height }; 43 | 44 | Clear.render(modal, buf); 45 | 46 | let outer_block = Block::default() 47 | .title("Capture Keybinding") 48 | .borders(Borders::ALL) 49 | .border_type(BorderType::Double); 50 | let outer_inner = outer_block.inner(modal); 51 | outer_block.render(modal, buf); 52 | 53 | let instructions = self.build_instructions_from_config(); 54 | let layout = split_dialog_area(outer_inner, self.show_instructions, 55 | if instructions.is_empty() { None } else { Some(instructions.as_str()) }); 56 | let content_area = layout.content_area; 57 | 58 | let message = if self.pressed_display.is_empty() { 59 | "Press keys, Enter to apply, Esc to cancel".to_string() 60 | } else { 61 | self.pressed_display.clone() 62 | }; 63 | 64 | let inner_block = Block::default().borders(Borders::ALL).title("New Binding"); 65 | let inner = inner_block.inner(content_area); 66 | inner_block.render(content_area, buf); 67 | 68 | let p = Paragraph::new(message).wrap(Wrap { trim: true }); 69 | p.render(inner, buf); 70 | 71 | // Render instructions panel if enabled 72 | if self.show_instructions { 73 | if let Some(instr_area) = layout.instructions_area { 74 | let p = Paragraph::new(instructions) 75 | .block(Block::default().borders(Borders::ALL).title("Instructions")) 76 | .style(Style::default().fg(Color::Yellow)) 77 | .wrap(Wrap { trim: true }); 78 | p.render(instr_area, buf); 79 | } 80 | } 81 | } 82 | } 83 | 84 | impl Component for KeybindingCaptureDialog { 85 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 86 | self.config = config; 87 | Ok(()) 88 | } 89 | 90 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 91 | if key.kind != KeyEventKind::Press { return Ok(None); } 92 | 93 | // Respect global actions for confirm/cancel 94 | if let Some(a) = self.config.action_for_key(config::Mode::Global, key) { 95 | match a { 96 | Action::Enter => return Ok(Some(Action::ConfirmRebinding)), 97 | Action::Escape => return Ok(Some(Action::CancelRebinding)), 98 | _ => {} 99 | } 100 | } 101 | 102 | // Only allow a single key combination; replace any previous value 103 | self.pressed_keys.clear(); 104 | self.pressed_keys.push(key); 105 | let key_strs: Vec = self.pressed_keys.iter().map(config::key_event_to_string).collect(); 106 | self.pressed_display = key_strs.join(" "); 107 | Ok(None) 108 | } 109 | 110 | fn draw(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> { 111 | self.render(area, frame.buffer_mut()); 112 | Ok(()) 113 | } 114 | } 115 | 116 | 117 | -------------------------------------------------------------------------------- /src/dialog/message_dialog.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::{KeyEvent, KeyEventKind}; 3 | use ratatui::prelude::*; 4 | use ratatui::widgets::{Block, Borders, BorderType}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::action::Action; 8 | use crate::components::Component; 9 | use crate::config::Config; 10 | 11 | /// Simple reusable message dialog for transient notifications (info/success/warning). 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub struct MessageDialog { 14 | title: String, 15 | pub message: String, 16 | pub show_instructions: bool, 17 | #[serde(skip)] 18 | pub config: Config, 19 | } 20 | 21 | impl MessageDialog { 22 | pub fn new(message: impl Into) -> Self { 23 | Self { 24 | title: "Message".to_string(), 25 | message: message.into(), 26 | show_instructions: true, 27 | config: Config::default(), 28 | } 29 | } 30 | 31 | pub fn with_title(message: impl Into, title: impl Into) -> Self { 32 | Self { 33 | title: title.into(), 34 | message: message.into(), 35 | show_instructions: true, 36 | config: Config::default(), 37 | } 38 | } 39 | 40 | pub fn set_message(&mut self, message: impl Into) { self.message = message.into(); } 41 | pub fn set_title(&mut self, title: impl Into) { self.title = title.into(); } 42 | 43 | /// Build instructions string from configured keybindings 44 | fn build_instructions_from_config(&self) -> String { 45 | self.config.actions_to_instructions(&[ 46 | (crate::config::Mode::Global, crate::action::Action::Enter), 47 | (crate::config::Mode::Global, crate::action::Action::Escape), 48 | ]) 49 | } 50 | 51 | pub fn render(&self, area: Rect, buf: &mut Buffer) { 52 | let modal = area; 53 | 54 | let block = Block::default() 55 | .title(self.title.as_str()) 56 | .borders(Borders::ALL) 57 | .border_type(BorderType::Double); 58 | let inner = block.inner(modal); 59 | block.render(modal, buf); 60 | 61 | let wrap_width = inner.width.saturating_sub(2) as usize; 62 | let wrapped = textwrap::wrap(&self.message, wrap_width); 63 | 64 | for (i, line) in wrapped.iter().enumerate() { 65 | if i as u16 >= inner.height { break; } 66 | buf.set_string(inner.x + 1, inner.y + i as u16, line, Style::default().fg(Color::White)); 67 | } 68 | 69 | let instructions = self.build_instructions_from_config(); 70 | let hint = if instructions.is_empty() { 71 | "Enter/Esc to close".to_string() 72 | } else { 73 | instructions 74 | }; 75 | let hint_x = inner.x + inner.width.saturating_sub(hint.len() as u16 + 1); 76 | let hint_y = inner.y + inner.height.saturating_sub(1); 77 | buf.set_string(hint_x, hint_y, hint, Style::default().fg(Color::Gray)); 78 | } 79 | } 80 | 81 | impl Component for MessageDialog { 82 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 83 | self.config = config; 84 | Ok(()) 85 | } 86 | 87 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 88 | if key.kind == KeyEventKind::Press { 89 | // First, honor config-driven Global actions 90 | if let Some(global_action) = self.config.action_for_key(crate::config::Mode::Global, key) { 91 | match global_action { 92 | Action::Escape => return Ok(Some(Action::DialogClose)), 93 | Action::Enter => return Ok(Some(Action::DialogClose)), 94 | Action::ToggleInstructions => { 95 | self.show_instructions = !self.show_instructions; 96 | return Ok(None); 97 | } 98 | _ => {} 99 | } 100 | } 101 | 102 | // Next, check for dialog-specific actions 103 | if let Some(dialog_action) = self.config.action_for_key(crate::config::Mode::MessageDialog, key) { 104 | match dialog_action { 105 | Action::Escape => return Ok(Some(Action::DialogClose)), 106 | Action::Enter => return Ok(Some(Action::DialogClose)), 107 | _ => {} 108 | } 109 | } 110 | } 111 | Ok(None) 112 | } 113 | 114 | fn draw(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> { 115 | self.render(area, frame.buffer_mut()); 116 | Ok(()) 117 | } 118 | } 119 | 120 | 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DataTUI 2 | 3 | A fast, keyboard‑first terminal data viewer built with Rust and Ratatui. DataTUI lets you explore CSV/TSV, Excel, and SQLite data with tabs, sorting, filtering, SQL (via Polars), and more. 4 | 5 | ![DataTUI hero screenshot](docs/assets/hero.png) 6 | 7 | 8 | ## Features 9 | 10 | - Tabbed data views with quick navigation 11 | - CSV/TSV, Excel, and SQLite import flows 12 | - Polars‑backed SQL queries and lazy evaluation 13 | - Sorting, filtering (builder dialog + quick filters), column width management 14 | - Find, Find All with contextual results, and value viewer with optional auto‑expand 15 | - JMESPath transforms and Add Columns from expressions 16 | - Workspace persistence (state + current views) with Parquet snapshots 17 | 18 | ## Install 19 | 20 | From source in this repo: 21 | 22 | ```bash 23 | cargo build --release 24 | # binary at target/release/datatui 25 | ``` 26 | 27 | Or run locally: 28 | 29 | ```bash 30 | cargo run --release --bin datatui 31 | ``` 32 | 33 | ## Usage 34 | 35 | ```bash 36 | datatui [--logging error|warn|info|debug|trace] 37 | ``` 38 | 39 | ## Loading data with --load 40 | 41 | Preload one or more datasets on startup by repeating `--load`. Each spec is: 42 | 43 | `kind:path;key=value;flag` 44 | 45 | - Multiple `--load` flags are allowed; each adds a dataset to import. 46 | - `path` can be a file path, a glob pattern (`*`, `?`, `[abc]`), or `STDIN`/`-`. 47 | - Bare flags without `=value` are treated as boolean `true`. 48 | 49 | ### Kinds and options 50 | 51 | - CSV/TSV/TEXT: `text`, `csv`, `tsv`, `psv` 52 | - Options: `delim`/`delimiter` (`,`, `comma`, `tab`, `|`, `pipe`, `psv`, `space`, or `char:x`), `header`/`has_header` (`true|false`), `quote`/`quote_char` (char or `none`), `escape`/`escape_char` (char or `none`), `merge` (`true|false`). 53 | - Examples: 54 | - `--load 'csv:C:\\data\\sales.csv;header=true'` 55 | - `--load 'tsv:C:\\data\\export.tsv;header=false'` 56 | - `--load 'text:C:\\data\\file.txt;delim=tab;quote=none'` 57 | - `--load 'psv:C:\\data\\*.psv;header=true;merge=true'` (globs; merged into a temp file) 58 | 59 | - Excel: `xlsx`, `xls` 60 | - Options: `all_sheets` (`true|false`, default `true`), `sheets`/`sheet` (comma list). 61 | - Examples: 62 | - `--load 'xlsx:C:\\data\\book.xlsx'` (all sheets) 63 | - `--load 'xlsx:C:\\data\\book.xlsx;all_sheets=false;sheets=Sheet1,Sheet3'` 64 | 65 | - SQLite: `sqlite`, `db` 66 | - Options: `import_all_tables` (`true|false`), `table` (single), `tables` (comma list). 67 | - Examples: 68 | - `--load 'sqlite:C:\\db\\app.sqlite;table=users'` 69 | - `--load 'sqlite:C:\\db\\app.sqlite;tables=users,orders'` 70 | - `--load 'sqlite:C:\\db\\app.sqlite;import_all_tables=true'` 71 | 72 | - Parquet: `parquet` 73 | - Options: none 74 | - Example: `--load 'parquet:C:\\data\\metrics.parquet'` 75 | 76 | - JSON / NDJSON: `json`, `jsonl`, `ndjson` 77 | - Options: `ndjson` (`true|false`), `records` (path to array of records), `merge` (`true|false`, only for NDJSON). 78 | - Examples: 79 | - `--load 'json:C:\\data\\records.json;records=data.items'` 80 | - `--load 'jsonl:C:\\logs\\*.jsonl;merge=true'` (globs; merged into a temp `.jsonl`) 81 | 82 | ### Reading from STDIN 83 | 84 | - Use `STDIN` or `-` as the path. The temp file extension is inferred from `kind` and options. 85 | - Examples: 86 | - Bash: `cat data.json | datatui --load 'json:STDIN'` 87 | - PowerShell: `Get-Content -Raw data.json | datatui --load 'json:-'` 88 | 89 | ### Multiple datasets 90 | 91 | Repeat `--load` to queue several datasets; they will import automatically on startup. 92 | 93 | 94 | ## Customizing key bindings 95 | - Default user config file: `~/.datatui-config.json5`. On first run, this file is created from built‑in defaults. Override with `--config PATH`. 96 | - Keybindings are organized by mode (grouping) like `Global`, `DataTabManager`, and per‑dialog modes. The on‑screen Instructions bar shows current keys for common actions. 97 | - Open the Keybindings dialog, select a grouping, highlight an action, choose Start Rebinding, press your key combo, then press Enter to apply. Use Clear to remove a binding. 98 | - Save As in the Keybindings dialog exports only the keybindings as JSON5. To make them default, save to your user config path or pass the file via `--config PATH`. 99 | 100 | ## Workspaces and persistence 101 | 102 | When a valid workspace folder is set in Project Settings, DataTUI persists: 103 | 104 | - State file: `datatui_workspace_state.json` 105 | - Settings file: `datatui_workspace_settings.json` 106 | - Current DataFrame snapshots: `.datatui/tabs/.parquet` 107 | 108 | On exit or when requested, TDV writes the current view to Parquet per tab (if applicable) so you can quickly resume where you left off. 109 | 110 | ## Development 111 | 112 | - Rust toolchain required 113 | - Build: `cargo build` (or `--release`) 114 | - Run tests (if present): `cargo test` 115 | 116 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #0b0d12; 3 | --panel: #0f1218; 4 | --text: #e8ecf1; 5 | --muted: #a9b3be; 6 | --brand: #39c2ff; 7 | --brand-2: #7affb2; 8 | --zebra-1: #0f1218; 9 | --zebra-2: #121622; 10 | --border: #1c2230; 11 | } 12 | 13 | * { box-sizing: border-box; } 14 | html, body { height: 100%; } 15 | body { 16 | margin: 0; 17 | font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji; 18 | background: linear-gradient(180deg, var(--bg), #0c111b 40%, var(--bg)); 19 | color: var(--text); 20 | } 21 | 22 | .container { 23 | width: 100%; 24 | max-width: 1120px; 25 | margin: 0 auto; 26 | padding: 0 20px; 27 | } 28 | 29 | /* Header */ 30 | .site-header { 31 | position: sticky; 32 | top: 0; 33 | background: rgba(12, 17, 27, 0.7); 34 | backdrop-filter: blur(8px); 35 | border-bottom: 1px solid var(--border); 36 | z-index: 50; 37 | } 38 | .nav { 39 | display: flex; 40 | align-items: center; 41 | justify-content: space-between; 42 | height: 64px; 43 | } 44 | .logo { 45 | font-weight: 800; 46 | letter-spacing: 0.4px; 47 | color: var(--text); 48 | text-decoration: none; 49 | } 50 | .main-nav a { 51 | color: var(--muted); 52 | text-decoration: none; 53 | margin-left: 18px; 54 | } 55 | .main-nav a:hover { color: var(--text); } 56 | 57 | /* Hero */ 58 | .hero { padding: 64px 0 40px; } 59 | .hero-grid { 60 | display: grid; 61 | grid-template-columns: 1.1fr 0.9fr; 62 | gap: 32px; 63 | align-items: center; 64 | } 65 | .hero h1 { 66 | font-size: 48px; 67 | line-height: 1.1; 68 | margin: 0 0 8px; 69 | } 70 | .tag { color: var(--brand); font-size: 20px; margin: 0 0 16px; } 71 | .sub { color: var(--muted); max-width: 56ch; } 72 | .cta { margin-top: 20px; } 73 | .btn { 74 | display: inline-block; 75 | padding: 10px 16px; 76 | border-radius: 8px; 77 | border: 1px solid var(--border); 78 | color: var(--text); 79 | text-decoration: none; 80 | margin-right: 12px; 81 | } 82 | .btn.primary { background: linear-gradient(90deg, var(--brand), var(--brand-2)); color: #001018; border: none; } 83 | 84 | .screenshot-placeholder, 85 | .feature-media { 86 | border: 1px dashed #2b3447; 87 | background: repeating-linear-gradient(45deg, #0e1422, #0e1422 10px, #0b111e 10px, #0b111e 20px); 88 | border-radius: 10px; 89 | height: 280px; 90 | position: relative; 91 | } 92 | .placeholder-label { 93 | position: absolute; 94 | right: 12px; 95 | bottom: 10px; 96 | font-size: 12px; 97 | color: var(--muted); 98 | } 99 | 100 | /* Media */ 101 | .feature-media img, .hero-art img { 102 | width: 100%; 103 | height: auto; 104 | display: block; 105 | border-radius: 10px; 106 | } 107 | 108 | /* Sections */ 109 | .section-title { font-size: 28px; margin: 24px 0; } 110 | 111 | .features .feature:nth-of-type(odd) { background: var(--zebra-1); } 112 | .features .feature:nth-of-type(even) { background: var(--zebra-2); } 113 | .feature { padding: 40px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } 114 | .feature-grid { 115 | display: grid; 116 | grid-template-columns: 1fr 1fr; 117 | gap: 28px; 118 | align-items: center; 119 | } 120 | .feature-grid.reverse { direction: rtl; } 121 | .feature-grid.reverse > * { direction: ltr; } 122 | .feature-text h3 { margin: 0 0 8px; font-size: 22px; } 123 | .feature-text p { color: var(--muted); margin: 0 0 10px; } 124 | .feature-text ul { margin: 8px 0 0; padding-left: 18px; color: var(--muted); } 125 | 126 | /* Install */ 127 | .install { padding: 48px 0 64px; } 128 | pre { background: #0d1220; border: 1px solid var(--border); border-radius: 10px; padding: 14px; overflow: auto; } 129 | code { color: #d8e2f5; } 130 | 131 | /* Footer */ 132 | .site-footer { border-top: 1px solid var(--border); background: #0b0f19; padding: 28px 0; } 133 | .footer-grid { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; } 134 | .site-footer nav a { color: var(--muted); text-decoration: none; margin: 0 10px; } 135 | .site-footer nav a:hover { color: var(--text); } 136 | .muted { color: var(--muted); } 137 | .small { font-size: 12px; } 138 | 139 | /* Responsive */ 140 | @media (max-width: 900px) { 141 | .hero-grid, .feature-grid, .footer-grid { grid-template-columns: 1fr; } 142 | .hero { padding-top: 36px; } 143 | } 144 | 145 | /* More features */ 146 | .more-features { padding: 48px 0 72px; background: #0a0e18; border-top: 1px solid var(--border); } 147 | .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } 148 | .card { background: #0c1220; border: 1px solid var(--border); border-radius: 12px; padding: 16px; } 149 | .card h3 { margin: 0 0 6px; font-size: 18px; } 150 | .card p { margin: 0 0 8px; color: var(--muted); } 151 | .card ul { margin: 0; padding-left: 18px; color: var(--muted); } 152 | @media (max-width: 900px) { 153 | .cards { grid-template-columns: 1fr; } 154 | } 155 | 156 | 157 | -------------------------------------------------------------------------------- /src/data_import_types.rs: -------------------------------------------------------------------------------- 1 | //! DataImportTypes: Enum and structs for different data import configurations 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::path::PathBuf; 5 | use crate::dialog::csv_options_dialog::CsvImportOptions; 6 | use crate::dialog::xlsx_options_dialog::XlsxImportOptions; 7 | use crate::dialog::sqlite_options_dialog::SqliteImportOptions; 8 | use crate::dialog::parquet_options_dialog::ParquetImportOptions; 9 | use crate::dialog::json_options_dialog::JsonImportOptions; 10 | 11 | /// Text file import configuration (CSV, TSV, etc.) 12 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 13 | pub struct TextImportConfig { 14 | pub file_path: PathBuf, 15 | pub options: CsvImportOptions, 16 | #[serde(default)] 17 | pub additional_paths: Vec, 18 | #[serde(default)] 19 | pub merge: bool, 20 | } 21 | 22 | /// Excel file import configuration 23 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 24 | pub struct ExcelImportConfig { 25 | pub file_path: PathBuf, 26 | pub options: XlsxImportOptions, 27 | } 28 | 29 | /// SQLite database import configuration 30 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 31 | pub struct SqliteImportConfig { 32 | pub file_path: PathBuf, 33 | pub options: SqliteImportOptions, 34 | pub table_name: Option, // Specific table to import (None means use options to determine) 35 | } 36 | 37 | /// Parquet file import configuration 38 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 39 | pub struct ParquetImportConfig { 40 | pub file_path: PathBuf, 41 | pub options: ParquetImportOptions, 42 | } 43 | 44 | /// JSON file import configuration 45 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 46 | pub struct JsonImportConfig { 47 | pub file_path: PathBuf, 48 | pub options: JsonImportOptions, 49 | #[serde(default)] 50 | pub additional_paths: Vec, 51 | #[serde(default)] 52 | pub merge: bool, 53 | } 54 | 55 | /// Enum that can store different types of import configurations 56 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 57 | pub enum DataImportConfig { 58 | Text(TextImportConfig), 59 | Excel(ExcelImportConfig), 60 | Sqlite(SqliteImportConfig), 61 | Parquet(ParquetImportConfig), 62 | Json(JsonImportConfig), 63 | } 64 | 65 | impl DataImportConfig { 66 | /// Get the file path for any import configuration 67 | pub fn file_path(&self) -> &PathBuf { 68 | match self { 69 | DataImportConfig::Text(config) => &config.file_path, 70 | DataImportConfig::Excel(config) => &config.file_path, 71 | DataImportConfig::Sqlite(config) => &config.file_path, 72 | DataImportConfig::Parquet(config) => &config.file_path, 73 | DataImportConfig::Json(config) => &config.file_path, 74 | } 75 | } 76 | 77 | /// Get a display name for the import type 78 | pub fn import_type_name(&self) -> &'static str { 79 | match self { 80 | DataImportConfig::Text(_) => "Text File", 81 | DataImportConfig::Excel(_) => "Excel File", 82 | DataImportConfig::Sqlite(_) => "SQLite Database", 83 | DataImportConfig::Parquet(_) => "Parquet File", 84 | DataImportConfig::Json(_) => "JSON File", 85 | } 86 | } 87 | 88 | /// Create a text import configuration from a file path and options 89 | pub fn text(file_path: PathBuf, options: CsvImportOptions) -> Self { 90 | DataImportConfig::Text(TextImportConfig { 91 | file_path, 92 | options, 93 | additional_paths: Vec::new(), 94 | merge: false, 95 | }) 96 | } 97 | 98 | /// Create an excel import configuration from a file path and options 99 | pub fn excel(file_path: PathBuf, options: XlsxImportOptions) -> Self { 100 | DataImportConfig::Excel(ExcelImportConfig { 101 | file_path, 102 | options, 103 | }) 104 | } 105 | 106 | /// Create a sqlite import configuration from a file path and options 107 | pub fn sqlite(file_path: PathBuf, options: SqliteImportOptions) -> Self { 108 | DataImportConfig::Sqlite(SqliteImportConfig { 109 | file_path, 110 | options, 111 | table_name: None, 112 | }) 113 | } 114 | 115 | /// Create a sqlite import configuration for a specific table 116 | pub fn sqlite_table(file_path: PathBuf, options: SqliteImportOptions, table_name: String) -> Self { 117 | DataImportConfig::Sqlite(SqliteImportConfig { 118 | file_path, 119 | options, 120 | table_name: Some(table_name), 121 | }) 122 | } 123 | 124 | /// Create a parquet import configuration from a file path and options 125 | pub fn parquet(file_path: PathBuf, options: ParquetImportOptions) -> Self { 126 | DataImportConfig::Parquet(ParquetImportConfig { 127 | file_path, 128 | options, 129 | }) 130 | } 131 | 132 | /// Create a json import configuration from a file path and options 133 | pub fn json(file_path: PathBuf, options: JsonImportOptions) -> Self { 134 | DataImportConfig::Json(JsonImportConfig { 135 | file_path, 136 | options, 137 | additional_paths: Vec::new(), 138 | merge: false, 139 | }) 140 | } 141 | } -------------------------------------------------------------------------------- /src/style.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::{Style, Color, Modifier}; 2 | use serde::{Serialize, Deserialize}; 3 | 4 | #[derive(Debug, Clone, Serialize, Deserialize)] 5 | pub struct StyleConfig { 6 | pub table_header: Style, 7 | pub table_cell: Style, 8 | pub table_border: Style, 9 | pub selected_row: Style, 10 | pub dialog: Style, 11 | pub error: Style, 12 | pub table_row_even: Style, 13 | pub table_row_odd: Style, 14 | pub cursor: CursorStyle, 15 | } 16 | 17 | #[derive(Debug, Clone, Serialize, Deserialize)] 18 | pub struct CursorStyle { 19 | /// Style for block cursor (used in simple text input fields) 20 | pub block: Style, 21 | /// Style for highlighted character cursor (used in search/find fields) 22 | pub highlighted: Style, 23 | /// Style for hidden cursor (used when field is not focused) 24 | pub hidden: Style, 25 | } 26 | 27 | impl Default for StyleConfig { 28 | fn default() -> Self { 29 | Self { 30 | table_header: Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 31 | table_cell: Style::default().fg(Color::White), 32 | table_border: Style::default().fg(Color::Gray), 33 | selected_row: Style::default().fg(Color::Black).bg(Color::Yellow), 34 | dialog: Style::default().fg(Color::White), 35 | error: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), 36 | table_row_even: Style::default().bg(Color::Rgb(30, 30, 30)), // dark gray 37 | table_row_odd: Style::default().bg(Color::Rgb(40, 40, 40)), // slightly lighter gray 38 | cursor: CursorStyle::default(), 39 | } 40 | } 41 | } 42 | 43 | impl Default for CursorStyle { 44 | fn default() -> Self { 45 | Self { 46 | block: Style::default().fg(Color::Black).bg(Color::White), 47 | highlighted: Style::default().fg(Color::Black).bg(Color::Yellow), 48 | hidden: Style::default().fg(Color::Gray), 49 | } 50 | } 51 | } 52 | 53 | impl StyleConfig { 54 | pub fn with_table_header(mut self, style: Style) -> Self { 55 | self.table_header = style; 56 | self 57 | } 58 | pub fn with_table_cell(mut self, style: Style) -> Self { 59 | self.table_cell = style; 60 | self 61 | } 62 | pub fn with_table_border(mut self, style: Style) -> Self { 63 | self.table_border = style; 64 | self 65 | } 66 | pub fn with_selected_row(mut self, style: Style) -> Self { 67 | self.selected_row = style; 68 | self 69 | } 70 | pub fn with_dialog(mut self, style: Style) -> Self { 71 | self.dialog = style; 72 | self 73 | } 74 | pub fn with_error(mut self, style: Style) -> Self { 75 | self.error = style; 76 | self 77 | } 78 | pub fn with_table_row_even(mut self, style: Style) -> Self { 79 | self.table_row_even = style; 80 | self 81 | } 82 | pub fn with_table_row_odd(mut self, style: Style) -> Self { 83 | self.table_row_odd = style; 84 | self 85 | } 86 | pub fn with_cursor(mut self, cursor: CursorStyle) -> Self { 87 | self.cursor = cursor; 88 | self 89 | } 90 | } 91 | 92 | impl CursorStyle { 93 | /// Get the block cursor style 94 | pub fn block(&self) -> Style { 95 | self.block 96 | } 97 | 98 | /// Get the highlighted cursor style 99 | pub fn highlighted(&self) -> Style { 100 | self.highlighted 101 | } 102 | 103 | /// Get the hidden cursor style 104 | pub fn hidden(&self) -> Style { 105 | self.hidden 106 | } 107 | 108 | /// Create a custom cursor style 109 | pub fn new(block: Style, highlighted: Style, hidden: Style) -> Self { 110 | Self { 111 | block, 112 | highlighted, 113 | hidden, 114 | } 115 | } 116 | } 117 | 118 | #[cfg(test)] 119 | mod tests { 120 | use super::*; 121 | use ratatui::style::{Color, Modifier}; 122 | 123 | #[test] 124 | fn test_default_styles() { 125 | let style = StyleConfig::default(); 126 | assert_eq!(style.table_header.fg, Some(Color::Yellow)); 127 | assert!(style.table_header.add_modifier.contains(Modifier::BOLD)); 128 | assert_eq!(style.table_cell.fg, Some(Color::White)); 129 | assert_eq!(style.table_border.fg, Some(Color::Gray)); 130 | assert_eq!(style.selected_row.fg, Some(Color::Black)); 131 | assert_eq!(style.selected_row.bg, Some(Color::Yellow)); 132 | assert_eq!(style.dialog.fg, Some(Color::White)); 133 | assert_eq!(style.error.fg, Some(Color::Red)); 134 | assert!(style.error.add_modifier.contains(Modifier::BOLD)); 135 | assert_eq!(style.table_row_even.bg, Some(Color::Rgb(30, 30, 30))); 136 | assert_eq!(style.table_row_odd.bg, Some(Color::Rgb(40, 40, 40))); 137 | assert_eq!(style.cursor.block.fg, Some(Color::Black)); 138 | assert_eq!(style.cursor.block.bg, Some(Color::White)); 139 | assert_eq!(style.cursor.highlighted.fg, Some(Color::Black)); 140 | assert_eq!(style.cursor.highlighted.bg, Some(Color::Yellow)); 141 | assert_eq!(style.cursor.hidden.fg, Some(Color::Gray)); 142 | } 143 | 144 | #[test] 145 | fn test_custom_styles() { 146 | let custom = StyleConfig::default() 147 | .with_table_header(Style::default().fg(Color::Green)) 148 | .with_error(Style::default().fg(Color::Magenta)); 149 | assert_eq!(custom.table_header.fg, Some(Color::Green)); 150 | assert_eq!(custom.error.fg, Some(Color::Magenta)); 151 | } 152 | } -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod datatable; 2 | pub mod fps; 3 | pub mod home; 4 | pub mod datatable_container; 5 | pub mod dialog_layout; 6 | 7 | use color_eyre::Result; 8 | use crossterm::event::{KeyEvent, MouseEvent}; 9 | use ratatui::{ 10 | Frame, 11 | layout::{Rect, Size}, 12 | }; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | 15 | use crate::{action::Action, config::Config, tui::Event}; 16 | 17 | /// `Component` is a trait that represents a visual and interactive element of the user interface. 18 | /// 19 | /// Implementors of this trait can be registered with the main application loop and will be able to 20 | /// receive events, update state, and be rendered on the screen. 21 | /// 22 | /// # Action-driven Communication 23 | /// 24 | /// All component-to-app and inter-component communication should use the `Action` enum for robust, type-safe messaging. When a user triggers an event 25 | /// (e.g., sorts a table, confirms a dialog), emit the appropriate `Action` variant from your event handlers. 26 | /// 27 | /// Example: 28 | /// ```rust 29 | /// use crossterm::event::{KeyEvent, KeyCode}; 30 | /// use datatui::action::Action; 31 | /// use datatui::dialog::sort_dialog::SortColumn; 32 | /// use color_eyre::Result; 33 | /// 34 | /// # fn example(key: KeyEvent) -> Result> { 35 | /// if key.code == KeyCode::Char('s') { 36 | /// return Ok(Some(Action::SortDialogApplied(vec![ 37 | /// SortColumn { name: "foo".to_string(), ascending: true } 38 | /// ]))); 39 | /// } 40 | /// Ok(None) 41 | /// # } 42 | /// ``` 43 | pub trait Component { 44 | /// Register an action handler that can send actions for processing if necessary. 45 | /// 46 | /// # Arguments 47 | /// 48 | /// * `tx` - An unbounded sender that can send actions. 49 | /// 50 | /// # Returns 51 | /// 52 | /// * `Result<()>` - An Ok result or an error. 53 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 54 | let _ = tx; // to appease clippy 55 | Ok(()) 56 | } 57 | /// Register a configuration handler that provides configuration settings if necessary. 58 | /// 59 | /// # Arguments 60 | /// 61 | /// * `config` - Configuration settings. 62 | /// 63 | /// # Returns 64 | /// 65 | /// * `Result<()>` - An Ok result or an error. 66 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 67 | let _ = config; // to appease clippy 68 | Ok(()) 69 | } 70 | /// Initialize the component with a specified area if necessary. 71 | /// 72 | /// # Arguments 73 | /// 74 | /// * `area` - Rectangular area to initialize the component within. 75 | /// 76 | /// # Returns 77 | /// 78 | /// * `Result<()>` - An Ok result or an error. 79 | fn init(&mut self, area: Size) -> Result<()> { 80 | let _ = area; // to appease clippy 81 | Ok(()) 82 | } 83 | /// Handle incoming events and produce actions if necessary. 84 | /// 85 | /// # Arguments 86 | /// 87 | /// * `event` - An optional event to be processed. 88 | /// 89 | /// # Returns 90 | /// 91 | /// * `Result>` - An action to be processed or none. 92 | fn handle_events(&mut self, event: Option) -> Result> { 93 | let action = match event { 94 | Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, 95 | Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, 96 | _ => None, 97 | }; 98 | Ok(action) 99 | } 100 | /// Handle key events and produce actions if necessary. 101 | /// 102 | /// # Arguments 103 | /// 104 | /// * `key` - A key event to be processed. 105 | /// 106 | /// # Returns 107 | /// 108 | /// * `Result>` - An action to be processed or none. 109 | /// 110 | /// # Action-driven Example 111 | /// Return `Some(Action::SortDialogApplied(...))` or `Some(Action::DialogClose)` to trigger app-level changes. 112 | /// 113 | /// Example: 114 | /// ```rust 115 | /// use crossterm::event::{KeyEvent, KeyCode}; 116 | /// use datatui::action::Action; 117 | /// use color_eyre::Result; 118 | /// 119 | /// # fn example(key: KeyEvent) -> Result> { 120 | /// if key.code == KeyCode::Char('e') { 121 | /// return Ok(Some(Action::DialogClose)); 122 | /// } 123 | /// Ok(None) 124 | /// # } 125 | /// ``` 126 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 127 | let _ = key; // to appease clippy 128 | Ok(None) 129 | } 130 | /// Handle mouse events and produce actions if necessary. 131 | /// 132 | /// # Arguments 133 | /// 134 | /// * `mouse` - A mouse event to be processed. 135 | /// 136 | /// # Returns 137 | /// 138 | /// * `Result>` - An action to be processed or none. 139 | fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { 140 | let _ = mouse; // to appease clippy 141 | Ok(None) 142 | } 143 | /// Update the state of the component based on a received action. (REQUIRED) 144 | /// 145 | /// # Arguments 146 | /// 147 | /// * `action` - An action that may modify the state of the component. 148 | /// 149 | /// # Returns 150 | /// 151 | /// * `Result>` - An action to be processed or none. 152 | fn update(&mut self, action: Action) -> Result> { 153 | let _ = action; // to appease clippy 154 | Ok(None) 155 | } 156 | /// Render the component on the screen. (REQUIRED) 157 | /// 158 | /// # Arguments 159 | /// 160 | /// * `f` - A frame used for rendering. 161 | /// * `area` - The area in which the component should be drawn. 162 | /// 163 | /// # Returns 164 | /// 165 | /// * `Result<()>` - An Ok result or an error. 166 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; 167 | } 168 | -------------------------------------------------------------------------------- /src/sql/mod.rs: -------------------------------------------------------------------------------- 1 | use polars::prelude::*; 2 | use polars_plan::dsl::udf::UserDefinedFunction; 3 | use polars_plan::dsl::GetOutput; 4 | use polars_sql::SQLContext; 5 | use polars_sql::function_registry::FunctionRegistry; 6 | use std::sync::Arc; 7 | use std::collections::HashMap; 8 | // use crate::providers::openai::Client as OpenAIClient; 9 | 10 | // (removed unused EmbeddingsProvider alias) 11 | 12 | fn upper_impl(columns: &mut [Column]) -> PolarsResult> { 13 | if columns.len() != 1 { 14 | return Err(PolarsError::ComputeError( 15 | "upper function expects exactly one argument".into(), 16 | )); 17 | } 18 | let s = columns[0].as_materialized_series(); 19 | let s = if s.dtype() == &DataType::String { s } else { &s.cast(&DataType::String)? }; 20 | let out_series = s.str()?.to_uppercase().into_series(); 21 | Ok(Some(out_series.into_column())) 22 | } 23 | 24 | pub fn register_all(ctx: &mut SQLContext) -> PolarsResult<()> { 25 | // Build UDF for upper(text) -> text 26 | let udf = UserDefinedFunction::new( 27 | "upper".into(), 28 | GetOutput::from_type(DataType::String), 29 | upper_impl, 30 | ); 31 | ctx.registry_mut().register("upper", udf)?; 32 | 33 | // // Build UDF for embed(text) -> List(Float32) 34 | // let embed_udf = UserDefinedFunction::new( 35 | // "embed".into(), 36 | // GetOutput::from_type(DataType::List(Box::new(DataType::Float32))), 37 | // embed_impl, 38 | // ); 39 | // ctx.registry_mut().register("embed", embed_udf)?; 40 | Ok(()) 41 | } 42 | 43 | // Custom UDF function registry to support dynamic registration 44 | #[derive(Default)] 45 | struct MyFunctionRegistry { 46 | functions: HashMap, 47 | } 48 | 49 | impl FunctionRegistry for MyFunctionRegistry { 50 | fn register(&mut self, name: &str, fun: UserDefinedFunction) -> PolarsResult<()> { 51 | self.functions.insert(name.to_string(), fun); 52 | Ok(()) 53 | } 54 | 55 | fn get_udf(&self, name: &str) -> PolarsResult> { 56 | Ok(self.functions.get(name).cloned()) 57 | } 58 | 59 | fn contains(&self, name: &str) -> bool { 60 | self.functions.contains_key(name) 61 | } 62 | } 63 | 64 | /// Create a SQLContext configured with a custom registry that supports registering UDFs. 65 | pub fn new_sql_context() -> SQLContext { 66 | SQLContext::new() 67 | .with_function_registry(Arc::new(MyFunctionRegistry::default())) 68 | } 69 | 70 | // // Embeddings provider configuration 71 | // // The provider takes a slice of unique strings and returns an embedding vector per input, in order. 72 | // lazy_static! { 73 | // static ref EMBEDDINGS_PROVIDER: RwLock> = RwLock::new(None); 74 | // } 75 | 76 | // /// Set the embeddings provider used by the `embed` SQL function. 77 | // pub fn set_embeddings_provider(provider: EmbeddingsProvider) { 78 | // *EMBEDDINGS_PROVIDER.write().expect("EMBEDDINGS_PROVIDER poisoned") = Some(provider); 79 | // } 80 | 81 | // /// Convenience: configure the embeddings provider to use OpenAI's embeddings for given client and model. 82 | // /// The closure runs Blocking HTTP; intended to be called from non-async SQL UDF context. 83 | // pub fn set_openai_embeddings_provider(client: OpenAIClient, model: Option) { 84 | // let selected_model = model.unwrap_or_else(|| "text-embedding-3-small".to_string()); 85 | // let provider = move |inputs: &[String]| -> PolarsResult>> { 86 | // match client.generate_embeddings(inputs, Some(&selected_model), None) { 87 | // Ok(v) => Ok(v), 88 | // Err(e) => Err(PolarsError::ComputeError(format!("OpenAI embeddings error: {e}").into())), 89 | // } 90 | // }; 91 | // set_embeddings_provider(Arc::new(provider)); 92 | // } 93 | 94 | // fn embed_impl(columns: &mut [Column]) -> PolarsResult> { 95 | // if columns.len() != 1 { 96 | // return Err(PolarsError::ComputeError("embed function expects exactly one argument".into())); 97 | // } 98 | // let mut s = columns[0].as_materialized_series().clone(); 99 | // if s.dtype() != &DataType::String { 100 | // s = s.cast(&DataType::String)?; 101 | // } 102 | 103 | // // Extract texts and build mapping unique_value -> unique_index 104 | // let len = s.len(); 105 | // let mut row_texts: Vec> = Vec::with_capacity(len); 106 | // let mut unique_index: HashMap = HashMap::new(); 107 | // let mut uniques: Vec = Vec::new(); 108 | // for i in 0..len { 109 | // let av_res = s.get(i); 110 | // if let Ok(av) = av_res { 111 | // if av.is_null() { 112 | // row_texts.push(None); 113 | // continue; 114 | // } 115 | // let text_val = av.str_value().to_string(); 116 | // row_texts.push(Some(text_val.clone())); 117 | // if !unique_index.contains_key(&text_val) { 118 | // let idx = uniques.len(); 119 | // unique_index.insert(text_val.clone(), idx); 120 | // uniques.push(text_val); 121 | // } 122 | // } else { 123 | // row_texts.push(None); 124 | // continue; 125 | // } 126 | // } 127 | 128 | // // Acquire provider 129 | // let provider = EMBEDDINGS_PROVIDER 130 | // .read() 131 | // .expect("EMBEDDINGS_PROVIDER poisoned") 132 | // .as_ref() 133 | // .cloned() 134 | // .ok_or_else(|| PolarsError::ComputeError("embed function provider not configured".into()))?; 135 | 136 | // // Compute embeddings only for unique values 137 | // let unique_embeddings: Vec> = (provider)(&uniques)?; 138 | // if unique_embeddings.len() != uniques.len() { 139 | // return Err(PolarsError::ComputeError("embeddings provider returned wrong length".into())); 140 | // } 141 | 142 | // // Map back to each row 143 | // let row_embeddings_iter = row_texts.into_iter().map(|opt_text| { 144 | // opt_text.map(|t| { 145 | // let idx = unique_index.get(&t).copied().unwrap(); 146 | // let v: &Vec = &unique_embeddings[idx]; 147 | // Series::new(PlSmallStr::EMPTY, v.clone()) 148 | // }) 149 | // }); 150 | // let mut lc: ListChunked = row_embeddings_iter.collect(); 151 | // lc.rename(PlSmallStr::EMPTY); 152 | // Ok(Some(lc.into_series().into_column())) 153 | // } 154 | -------------------------------------------------------------------------------- /src/dialog/styling/style_set_browser_dialog.rs: -------------------------------------------------------------------------------- 1 | //! StyleSetBrowserDialog: Dialog for browsing and importing style sets 2 | use ratatui::prelude::*; 3 | use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap, BorderType}; 4 | use color_eyre::Result; 5 | use crossterm::event::{KeyEvent, KeyEventKind}; 6 | use crate::action::Action; 7 | use crate::config::{Config, Mode}; 8 | use crate::style::StyleConfig; 9 | use crate::components::Component; 10 | use crate::components::dialog_layout::split_dialog_area; 11 | use crate::dialog::styling::style_set_manager::StyleSetManager; 12 | use crate::dialog::file_browser_dialog::{FileBrowserDialog, FileBrowserAction, FileBrowserMode}; 13 | 14 | /// StyleSetBrowserDialog: UI for browsing folders and importing style sets 15 | #[derive(Debug)] 16 | pub struct StyleSetBrowserDialog { 17 | pub style_set_manager: StyleSetManager, 18 | pub file_browser: Option, 19 | pub show_instructions: bool, 20 | pub config: Config, 21 | pub style: StyleConfig, 22 | } 23 | 24 | impl StyleSetBrowserDialog { 25 | /// Create a new StyleSetBrowserDialog 26 | pub fn new(style_set_manager: StyleSetManager) -> Self { 27 | Self { 28 | style_set_manager, 29 | file_browser: None, 30 | show_instructions: true, 31 | config: Config::default(), 32 | style: StyleConfig::default(), 33 | } 34 | } 35 | 36 | /// Build instructions string from configured keybindings 37 | fn build_instructions_from_config(&self) -> String { 38 | self.config.actions_to_instructions(&[ 39 | (Mode::Global, Action::Escape), 40 | (Mode::Global, Action::Enter), 41 | (Mode::Global, Action::ToggleInstructions), 42 | ]) 43 | } 44 | 45 | /// Render the dialog 46 | pub fn render(&self, area: Rect, buf: &mut Buffer) { 47 | Clear.render(area, buf); 48 | 49 | let instructions = self.build_instructions_from_config(); 50 | 51 | let outer_block = Block::default() 52 | .title("Style Set Browser") 53 | .borders(Borders::ALL) 54 | .border_type(BorderType::Double); 55 | let inner_area = outer_block.inner(area); 56 | outer_block.render(area, buf); 57 | 58 | let layout = split_dialog_area(inner_area, self.show_instructions, if instructions.is_empty() { None } else { Some(instructions.as_str()) }); 59 | let content_area = layout.content_area; 60 | let instructions_area = layout.instructions_area; 61 | 62 | if let Some(ref browser) = self.file_browser { 63 | browser.render(content_area, buf); 64 | } else { 65 | let block = Block::default() 66 | .title("Browse for Style Set Folders") 67 | .borders(Borders::ALL); 68 | block.render(content_area, buf); 69 | 70 | let message = "Use the file browser to select a folder containing YAML style set files."; 71 | let p = Paragraph::new(message) 72 | .alignment(Alignment::Center) 73 | .style(Style::default().fg(Color::Gray)); 74 | p.render(content_area, buf); 75 | } 76 | 77 | // Render instructions 78 | if self.show_instructions { 79 | if let Some(instr_area) = instructions_area { 80 | let p = Paragraph::new(instructions) 81 | .block(Block::default().borders(Borders::ALL).title("Instructions")) 82 | .style(Style::default().fg(Color::Yellow)) 83 | .wrap(Wrap { trim: true }); 84 | p.render(instr_area, buf); 85 | } 86 | } 87 | } 88 | 89 | /// Handle a key event 90 | pub fn handle_key_event_pub(&mut self, key: KeyEvent) -> Option { 91 | // Initialize file browser if needed 92 | if self.file_browser.is_none() { 93 | let mut browser = FileBrowserDialog::new( 94 | None, 95 | Some(vec!["yaml", "yml"]), 96 | true, // folder_only 97 | FileBrowserMode::Load, 98 | ); 99 | browser.register_config_handler(self.config.clone()); 100 | self.file_browser = Some(browser); 101 | } 102 | 103 | if let Some(ref mut browser) = self.file_browser { 104 | if let Some(action) = browser.handle_key_event(key) { 105 | match action { 106 | FileBrowserAction::Selected(path) => { 107 | // Load style sets from the selected folder 108 | if path.is_dir() { 109 | if let Err(e) = self.style_set_manager.load_from_folder(&path) { 110 | tracing::error!("Failed to load style sets from folder: {}", e); 111 | } else { 112 | return Some(Action::StyleSetBrowserDialogApplied( 113 | self.style_set_manager.get_enabled_identifiers() 114 | )); 115 | } 116 | } 117 | return Some(Action::CloseStyleSetBrowserDialog); 118 | } 119 | FileBrowserAction::Cancelled => { 120 | return Some(Action::CloseStyleSetBrowserDialog); 121 | } 122 | } 123 | } 124 | } 125 | 126 | if key.kind == KeyEventKind::Press { 127 | // Check Global actions 128 | if let Some(global_action) = self.config.action_for_key(Mode::Global, key) { 129 | match global_action { 130 | Action::Escape => { 131 | return Some(Action::CloseStyleSetBrowserDialog); 132 | } 133 | Action::ToggleInstructions => { 134 | self.show_instructions = !self.show_instructions; 135 | return None; 136 | } 137 | _ => {} 138 | } 139 | } 140 | } 141 | 142 | None 143 | } 144 | } 145 | 146 | impl Component for StyleSetBrowserDialog { 147 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 148 | let config_clone = config.clone(); 149 | self.config = config_clone.clone(); 150 | if let Some(ref mut browser) = self.file_browser { 151 | browser.register_config_handler(config_clone); 152 | } 153 | Ok(()) 154 | } 155 | 156 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 157 | Ok(self.handle_key_event_pub(key)) 158 | } 159 | 160 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 161 | self.render(area, frame.buffer_mut()); 162 | Ok(()) 163 | } 164 | } 165 | 166 | -------------------------------------------------------------------------------- /src/jmes/functions.rs: -------------------------------------------------------------------------------- 1 | use jmespath::{Context, Rcvar, Runtime}; 2 | use jmespath::functions::{ArgumentType, CustomFunction, Signature}; 3 | 4 | /// Register all custom JMESPath functions available to the application. 5 | pub fn register_custom_functions(runtime: &mut Runtime) { 6 | runtime.register_function( 7 | "keyvalue_to_object", 8 | Box::new(CustomFunction::new( 9 | Signature::new( 10 | vec![ 11 | ArgumentType::String, // input string 12 | ArgumentType::String, // key-value separator 13 | ArgumentType::String, // pair separator 14 | ], 15 | None 16 | ), 17 | Box::new(|args: &[Rcvar], _ctx: &mut Context| { 18 | // args[0]: input string 19 | // args[1]: key-value separator 20 | // args[2]: pair separator 21 | if args.len() != 3 { 22 | // Gracefully return empty object if wrong arity 23 | let var = jmespath::Variable::try_from(serde_json::json!({}))?; 24 | return Ok(Rcvar::new(var)); 25 | } 26 | let input: String = args[0] 27 | .as_string() 28 | .map(|s| s.as_str()) 29 | .unwrap_or("") 30 | .to_string(); 31 | let kv_sep: String = args[1] 32 | .as_string() 33 | .map(|s| s.as_str()) 34 | .unwrap_or("") 35 | .to_string(); 36 | let pair_sep: String = args[2] 37 | .as_string() 38 | .map(|s| s.as_str()) 39 | .unwrap_or("") 40 | .to_string(); 41 | 42 | let mut map = serde_json::Map::new(); 43 | 44 | for pair in input.split(&pair_sep) { 45 | let trimmed = pair.trim(); 46 | if trimmed.is_empty() { 47 | continue; 48 | } 49 | let mut split = trimmed.splitn(2, &kv_sep); 50 | let key = split.next().unwrap_or("").trim(); 51 | let value = split.next().unwrap_or("").trim(); 52 | if !key.is_empty() { 53 | map.insert(key.to_string(), serde_json::Value::String(value.to_string())); 54 | } 55 | } 56 | 57 | let var = jmespath::Variable::try_from(serde_json::Value::Object(map))?; 58 | Ok(Rcvar::new(var)) 59 | }), 60 | )), 61 | ); 62 | 63 | // to_upper(string) -> string 64 | runtime.register_function( 65 | "upper", 66 | Box::new(CustomFunction::new( 67 | Signature::new(vec![ArgumentType::String], None), 68 | Box::new(|args: &[Rcvar], _ctx: &mut Context| { 69 | let s = args 70 | .first() 71 | .and_then(|v| v.as_string()) 72 | .map(|s| s.to_string()) 73 | .unwrap_or_else(|| args[0].to_string()); 74 | let upper = s.to_uppercase(); 75 | Ok(Rcvar::new(jmespath::Variable::String(upper))) 76 | }), 77 | )), 78 | ); 79 | 80 | // to_lower(string) -> string 81 | runtime.register_function( 82 | "lower", 83 | Box::new(CustomFunction::new( 84 | Signature::new(vec![ArgumentType::String], None), 85 | Box::new(|args: &[Rcvar], _ctx: &mut Context| { 86 | let s = args 87 | .first() 88 | .and_then(|v| v.as_string()) 89 | .map(|s| s.to_string()) 90 | .unwrap_or_else(|| args[0].to_string()); 91 | let lower = s.to_lowercase(); 92 | Ok(Rcvar::new(jmespath::Variable::String(lower))) 93 | }), 94 | )), 95 | ); 96 | 97 | // format(format_string, values_array) -> string 98 | // Example: format('hello {}', ['world']) => 'hello world' 99 | runtime.register_function( 100 | "format", 101 | Box::new(CustomFunction::new( 102 | Signature::new( 103 | vec![ 104 | ArgumentType::Union(vec![ArgumentType::String, ArgumentType::Null]), // null or string 105 | ArgumentType::Array, // array of values to substitute 106 | ], 107 | None, 108 | ), 109 | Box::new(|args: &[Rcvar], _ctx: &mut Context| { 110 | if args.len() != 2 { 111 | return Ok(Rcvar::new(jmespath::Variable::String(String::new()))); 112 | } 113 | 114 | // If the format string is null, return null 115 | if args[0].is_null() { 116 | return Ok(Rcvar::new(jmespath::Variable::Null)); 117 | } 118 | 119 | // Accept only string for non-null; fall back to to_string for resilience 120 | let format_string: String = args[0] 121 | .as_string() 122 | .map(|s| s.as_str().to_string()) 123 | .unwrap_or_else(|| args[0].to_string()); 124 | 125 | let values_opt = args[1].as_array(); 126 | 127 | // When values is not an array, treat as empty substitution list. 128 | let mut result = String::new(); 129 | let mut is_first_part = true; 130 | let mut value_index: usize = 0; 131 | 132 | let parts = format_string.split("{}"); 133 | for part in parts { 134 | if is_first_part { 135 | result.push_str(part); 136 | is_first_part = false; 137 | continue; 138 | } 139 | 140 | // Insert next value if available; otherwise, preserve '{}' literal 141 | let substitution = if let Some(values) = values_opt { 142 | if let Some(value_var) = values.get(value_index) { 143 | let value_str = value_var 144 | .as_string() 145 | .map(|s| s.to_string()) 146 | .unwrap_or_else(|| value_var.to_string()); 147 | value_index += 1; 148 | value_str 149 | } else { 150 | "{}".to_string() 151 | } 152 | } else { 153 | "{}".to_string() 154 | }; 155 | 156 | result.push_str(&substitution); 157 | result.push_str(part); 158 | } 159 | 160 | Ok(Rcvar::new(jmespath::Variable::String(result))) 161 | }), 162 | )), 163 | ); 164 | } 165 | 166 | 167 | -------------------------------------------------------------------------------- /.config/config.json5: -------------------------------------------------------------------------------- 1 | { 2 | "keybindings": { 3 | "DataTabManager": { 4 | "": "OpenStyleSetManagerDialog", 5 | "": "OpenProjectSettingsDialog", 6 | "": "OpenDataManagementDialog", 7 | "": "MoveTabToFront", 8 | "": "MoveTabToBack", 9 | "": "MoveTabLeft", 10 | "": "MoveTabRight", 11 | "": "PrevTab", 12 | "": "NextTab", 13 | "": "SyncTabs", 14 | "": "OpenDataExportDialog" 15 | }, 16 | "Global": { 17 | "": "Quit", 18 | "": "Tab", 19 | "": "Up", 20 | "": "Down", 21 | "": "Left", 22 | "": "Right", 23 | "": "Escape", 24 | "": "Enter", 25 | "": "Backspace", 26 | "": "ToggleInstructions", 27 | "": "OpenKeybindings", 28 | "": "SelectAllText", 29 | "": "CopyText", 30 | "": "Paste", 31 | "": "DeleteWord" 32 | }, 33 | "DataTableContainer": { 34 | "": "OpenSortDialog", 35 | "": "QuickSortCurrentColumn", 36 | "": "OpenFilterDialog", 37 | "": "QuickFilterEqualsCurrentValue", 38 | "": "MoveSelectedColumnLeft", 39 | "": "MoveSelectedColumnRight", 40 | "": "OpenSqlDialog", 41 | "": "OpenJmesDialog", 42 | "": "OpenColumnOperationsDialog", 43 | "": "OpenEmbeddingsPromptDialog", 44 | "": "OpenFindDialog", 45 | "": "OpenDataframeDetailsDialog", 46 | "": "OpenColumnWidthDialog", 47 | "": "CopySelectedCell", 48 | "": "ToggleInstructions" 49 | }, 50 | "DataManagement": { 51 | "": "DeleteSelectedSource", 52 | "": "OpenDataImportDialog", 53 | "": "LoadAllPendingDatasets", 54 | "": "EditSelectedAlias" 55 | }, 56 | "DataImport": { 57 | "": "DataImportSelect", 58 | "": "ConfirmDataImport", 59 | "": "DataImportBack", 60 | }, 61 | "CsvOptions": { 62 | "": "Tab", 63 | "": "OpenFileBrowser", 64 | "": "Paste" 65 | }, 66 | "Sort": { 67 | "": "ToggleSortDirection", 68 | "": "RemoveSortColumn", 69 | "": "AddSortColumn" 70 | }, 71 | "Filter": { 72 | "": "AddFilter", 73 | "": "EditFilter", 74 | "": "DeleteFilter", 75 | "": "AddFilterGroup", 76 | "": "SaveFilter", 77 | "": "LoadFilter", 78 | "": "ResetFilters", 79 | "": "ToggleFilterGroupType" 80 | }, 81 | "Find": { 82 | "": "Tab", 83 | "": "ToggleSpace", 84 | "": "Delete" 85 | }, 86 | "FindAllResults": { 87 | "": "GoToFirst", 88 | "": "GoToLast", 89 | "": "PageUp", 90 | "": "PageDown" 91 | }, 92 | "SqlDialog": { 93 | "": "SelectAllText", 94 | "": "CopyText", 95 | "": "RunQuery", 96 | "": "CreateNewDataset", 97 | "": "RestoreDataFrame", 98 | "": "OpenSqlFileBrowser", 99 | "": "ClearText", 100 | "": "PasteText" 101 | }, 102 | "XlsxOptionsDialog": { 103 | "": "OpenXlsxFileBrowser", 104 | "": "PasteFilePath", 105 | "": "ToggleWorksheetLoad" 106 | }, 107 | "ParquetOptionsDialog": { 108 | "": "OpenParquetFileBrowser", 109 | "": "PasteParquetFilePath" 110 | }, 111 | "SqliteOptionsDialog": { 112 | "": "OpenSqliteFileBrowser", 113 | "": "ToggleTableSelection" 114 | }, 115 | "FileBrowser": { 116 | "": "FileBrowserPageUp", 117 | "": "FileBrowserPageDown", 118 | "": "NavigateToParent", 119 | "y": "ConfirmOverwrite", 120 | "n": "DenyOverwrite" 121 | }, 122 | "JmesPath": { 123 | "": "AddColumn", 124 | "": "EditColumn", 125 | "": "DeleteColumn", 126 | "": "ApplyTransform" 127 | }, 128 | "ColumnWidthDialog": { 129 | "": "ToggleAutoExpand", 130 | "h": "ToggleColumnHidden", 131 | "": "MoveColumnUp", 132 | "": "MoveColumnDown" 133 | }, 134 | "JsonOptionsDialog": { 135 | "": "OpenJsonFileBrowser", 136 | "": "PasteJsonFilePath", 137 | "": "ToggleNdjson" 138 | }, 139 | "AliasEdit": { 140 | "": "ClearText" 141 | }, 142 | "ColumnOperationOptions": { 143 | "": "ToggleField", 144 | "": "ToggleButtons" 145 | }, 146 | "DataFrameDetails": { 147 | "": "SwitchToPrevTab", 148 | "": "SwitchToNextTab", 149 | "": "ChangeColumnLeft", 150 | "": "ChangeColumnRight", 151 | "": "OpenSortChoice", 152 | "": "OpenCastOverlay", 153 | "": "AddFilterFromValue", 154 | "": "ExportCurrentTab", 155 | "": "NavigateHeatmapUp", 156 | "": "NavigateHeatmapDown", 157 | "": "NavigateHeatmapPageUp", 158 | "": "NavigateHeatmapPageDown", 159 | "": "NavigateHeatmapHome", 160 | "": "NavigateHeatmapEnd" 161 | }, 162 | "ProjectSettings": { 163 | "": "ToggleDataViewerOption" 164 | }, 165 | "TableExport": { 166 | "": "OpenFileBrowser", 167 | "": "Paste", 168 | "": "CopyFilePath", 169 | "": "ExportTable", 170 | "": "ToggleFormat" 171 | }, 172 | "KeybindingsDialog": { 173 | "": "OpenGroupingDropdown", 174 | "": "SelectPrevGrouping", 175 | "": "SelectNextGrouping", 176 | "": "StartRebinding", 177 | "": "SaveKeybindings", 178 | "": "SaveKeybindingsAs", 179 | "": "ResetKeybindings", 180 | "": "ClearBinding", 181 | "": "CancelRebinding" 182 | }, 183 | "StyleSetManagerDialog": { 184 | "": "OpenStyleSetBrowserDialog", 185 | "": "AddStyleSet", 186 | "": "RemoveStyleSet", 187 | "": "ImportStyleSet", 188 | "": "ExportStyleSet", 189 | "": "EditStyleSet", 190 | "": "DisableStyleSet", 191 | "": "ToggleCategoryPanel", 192 | "": "FocusCategoryTree", 193 | "": "FocusStyleSetTable" 194 | }, 195 | "StyleSetEditorDialog": { 196 | "": "AddStyleRule", 197 | "": "EditStyleRule", 198 | "": "DeleteStyleRule", 199 | "": "MoveRuleUp", 200 | "": "MoveRuleDown", 201 | "": "SaveStyleSet" 202 | }, 203 | "StyleRuleEditorDialog": { 204 | "": "SaveStyleSet" 205 | }, 206 | "ApplicationScopeEditorDialog": { 207 | "": "OpenForegroundColorPicker", 208 | "": "OpenBackgroundColorPicker", 209 | "": "ClearForeground", 210 | "": "ClearBackground" 211 | }, 212 | "ColorPickerDialog": { 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // Remove this once you start using the code 2 | 3 | use std::{ 4 | io::{Stdout, stdout}, 5 | ops::{Deref, DerefMut}, 6 | time::Duration, 7 | }; 8 | 9 | use color_eyre::Result; 10 | use crossterm::{ 11 | cursor, 12 | event::{ 13 | DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, 14 | Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, 15 | }, 16 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 17 | }; 18 | use futures::{FutureExt, StreamExt}; 19 | use ratatui::backend::CrosstermBackend as Backend; 20 | use serde::{Deserialize, Serialize}; 21 | use tokio::{ 22 | sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, 23 | task::JoinHandle, 24 | time::interval, 25 | }; 26 | use tokio_util::sync::CancellationToken; 27 | use tracing::error; 28 | 29 | #[derive(Clone, Debug, Serialize, Deserialize)] 30 | pub enum Event { 31 | Init, 32 | Quit, 33 | Error, 34 | Closed, 35 | Tick, 36 | Render, 37 | FocusGained, 38 | FocusLost, 39 | Paste(String), 40 | Key(KeyEvent), 41 | Mouse(MouseEvent), 42 | Resize(u16, u16), 43 | } 44 | 45 | pub struct Tui { 46 | pub terminal: ratatui::Terminal>, 47 | pub task: JoinHandle<()>, 48 | pub cancellation_token: CancellationToken, 49 | pub event_rx: UnboundedReceiver, 50 | pub event_tx: UnboundedSender, 51 | pub frame_rate: f64, 52 | pub tick_rate: f64, 53 | pub mouse: bool, 54 | pub paste: bool, 55 | } 56 | 57 | impl Tui { 58 | pub fn new() -> Result { 59 | let (event_tx, event_rx) = mpsc::unbounded_channel(); 60 | Ok(Self { 61 | terminal: ratatui::Terminal::new(Backend::new(stdout()))?, 62 | task: tokio::spawn(async {}), 63 | cancellation_token: CancellationToken::new(), 64 | event_rx, 65 | event_tx, 66 | frame_rate: 60.0, 67 | tick_rate: 4.0, 68 | mouse: false, 69 | paste: false, 70 | }) 71 | } 72 | 73 | pub fn tick_rate(mut self, tick_rate: f64) -> Self { 74 | self.tick_rate = tick_rate; 75 | self 76 | } 77 | 78 | pub fn frame_rate(mut self, frame_rate: f64) -> Self { 79 | self.frame_rate = frame_rate; 80 | self 81 | } 82 | 83 | pub fn mouse(mut self, mouse: bool) -> Self { 84 | self.mouse = mouse; 85 | self 86 | } 87 | 88 | pub fn paste(mut self, paste: bool) -> Self { 89 | self.paste = paste; 90 | self 91 | } 92 | 93 | pub fn start(&mut self) { 94 | self.cancel(); // Cancel any existing task 95 | self.cancellation_token = CancellationToken::new(); 96 | let event_loop = Self::event_loop( 97 | self.event_tx.clone(), 98 | self.cancellation_token.clone(), 99 | self.tick_rate, 100 | self.frame_rate, 101 | ); 102 | self.task = tokio::spawn(async { 103 | event_loop.await; 104 | }); 105 | } 106 | 107 | async fn event_loop( 108 | event_tx: UnboundedSender, 109 | cancellation_token: CancellationToken, 110 | tick_rate: f64, 111 | frame_rate: f64, 112 | ) { 113 | let mut event_stream = EventStream::new(); 114 | let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); 115 | let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); 116 | 117 | // if this fails, then it's likely a bug in the calling code 118 | event_tx 119 | .send(Event::Init) 120 | .expect("failed to send init event"); 121 | loop { 122 | let event = tokio::select! { 123 | _ = cancellation_token.cancelled() => { 124 | break; 125 | } 126 | _ = tick_interval.tick() => Event::Tick, 127 | _ = render_interval.tick() => Event::Render, 128 | crossterm_event = event_stream.next().fuse() => match crossterm_event { 129 | Some(Ok(event)) => match event { 130 | CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), 131 | CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), 132 | CrosstermEvent::Resize(x, y) => Event::Resize(x, y), 133 | CrosstermEvent::FocusLost => Event::FocusLost, 134 | CrosstermEvent::FocusGained => Event::FocusGained, 135 | CrosstermEvent::Paste(s) => Event::Paste(s), 136 | _ => continue, // ignore other events 137 | } 138 | Some(Err(_)) => Event::Error, 139 | None => break, // the event stream has stopped and will not produce any more events 140 | }, 141 | }; 142 | if event_tx.send(event).is_err() { 143 | // the receiver has been dropped, so there's no point in continuing the loop 144 | break; 145 | } 146 | } 147 | cancellation_token.cancel(); 148 | } 149 | 150 | pub fn stop(&self) -> Result<()> { 151 | self.cancel(); 152 | let mut counter = 0; 153 | while !self.task.is_finished() { 154 | std::thread::sleep(Duration::from_millis(1)); 155 | counter += 1; 156 | if counter > 50 { 157 | self.task.abort(); 158 | } 159 | if counter > 100 { 160 | error!("Failed to abort task in 100 milliseconds for unknown reason"); 161 | break; 162 | } 163 | } 164 | Ok(()) 165 | } 166 | 167 | pub fn enter(&mut self) -> Result<()> { 168 | crossterm::terminal::enable_raw_mode()?; 169 | crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; 170 | if self.mouse { 171 | crossterm::execute!(stdout(), EnableMouseCapture)?; 172 | } 173 | if self.paste { 174 | crossterm::execute!(stdout(), EnableBracketedPaste)?; 175 | } 176 | self.start(); 177 | Ok(()) 178 | } 179 | 180 | pub fn exit(&mut self) -> Result<()> { 181 | self.stop()?; 182 | if crossterm::terminal::is_raw_mode_enabled()? { 183 | self.flush()?; 184 | if self.paste { 185 | crossterm::execute!(stdout(), DisableBracketedPaste)?; 186 | } 187 | if self.mouse { 188 | crossterm::execute!(stdout(), DisableMouseCapture)?; 189 | } 190 | crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; 191 | crossterm::terminal::disable_raw_mode()?; 192 | } 193 | Ok(()) 194 | } 195 | 196 | pub fn cancel(&self) { 197 | self.cancellation_token.cancel(); 198 | } 199 | 200 | pub fn suspend(&mut self) -> Result<()> { 201 | self.exit()?; 202 | #[cfg(not(windows))] 203 | signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; 204 | Ok(()) 205 | } 206 | 207 | pub fn resume(&mut self) -> Result<()> { 208 | self.enter()?; 209 | Ok(()) 210 | } 211 | 212 | pub async fn next_event(&mut self) -> Option { 213 | self.event_rx.recv().await 214 | } 215 | } 216 | 217 | impl Deref for Tui { 218 | type Target = ratatui::Terminal>; 219 | 220 | fn deref(&self) -> &Self::Target { 221 | &self.terminal 222 | } 223 | } 224 | 225 | impl DerefMut for Tui { 226 | fn deref_mut(&mut self) -> &mut Self::Target { 227 | &mut self.terminal 228 | } 229 | } 230 | 231 | impl Drop for Tui { 232 | fn drop(&mut self) { 233 | self.exit().unwrap(); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/dialog/styling/style_set_manager.rs: -------------------------------------------------------------------------------- 1 | //! StyleSetManager: Manages loading, saving, and enabling/disabling StyleSets 2 | use std::collections::BTreeMap; 3 | use std::path::{Path, PathBuf}; 4 | use std::fs; 5 | use color_eyre::Result; 6 | use serde_yaml; 7 | use crate::dialog::styling::style_set::StyleSet; 8 | 9 | /// Manages all StyleSets, including loading from folders and tracking enabled sets 10 | #[derive(Debug, Clone)] 11 | pub struct StyleSetManager { 12 | /// All loaded style sets, keyed by their identifier (name or path) 13 | style_sets: BTreeMap, 14 | /// Set of enabled style set identifiers 15 | enabled_sets: std::collections::HashSet, 16 | /// Folders that have been loaded 17 | loaded_folders: Vec, 18 | } 19 | 20 | impl StyleSetManager { 21 | /// Create a new StyleSetManager 22 | pub fn new() -> Self { 23 | Self { 24 | style_sets: BTreeMap::new(), 25 | enabled_sets: std::collections::HashSet::new(), 26 | loaded_folders: Vec::new(), 27 | } 28 | } 29 | 30 | /// Load all YAML style set files from a folder 31 | pub fn load_from_folder(&mut self, folder_path: &Path) -> Result> { 32 | let mut loaded_names = Vec::new(); 33 | 34 | if !folder_path.is_dir() { 35 | return Err(color_eyre::eyre::eyre!("Path is not a directory: {}", folder_path.display())); 36 | } 37 | 38 | // Read all .yaml and .yml files in the folder 39 | for entry in fs::read_dir(folder_path)? { 40 | let entry = entry?; 41 | let path = entry.path(); 42 | 43 | if path.is_file() { 44 | let ext = path.extension() 45 | .and_then(|s| s.to_str()) 46 | .unwrap_or(""); 47 | 48 | if ext == "yaml" || ext == "yml" { 49 | match self.load_from_file(&path) { 50 | Ok(name) => { 51 | loaded_names.push(name); 52 | } 53 | Err(e) => { 54 | tracing::warn!("Failed to load style set from {}: {}", path.display(), e); 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | // Track this folder as loaded 62 | if !self.loaded_folders.contains(&folder_path.to_path_buf()) { 63 | self.loaded_folders.push(folder_path.to_path_buf()); 64 | } 65 | 66 | Ok(loaded_names) 67 | } 68 | 69 | /// Load a single style set from a YAML file 70 | pub fn load_from_file(&mut self, file_path: &Path) -> Result { 71 | let content = fs::read_to_string(file_path)?; 72 | // First try direct YAML deserialization (supports legacy `!Variant` tags). 73 | // If that fails, fall back to YAML Value -> JSON Value -> StyleSet to accept 74 | // tag-free, externally tagged maps (e.g., `logic: { Conditional: {...} }`). 75 | let style_set: StyleSet = match serde_yaml::from_str(&content) { 76 | Ok(s) => s, 77 | Err(e) => { 78 | // Fallback path 79 | let yaml_val: serde_yaml::Value = serde_yaml::from_str(&content) 80 | .map_err(|e2| color_eyre::eyre::eyre!("Failed to parse YAML: {}", e2))?; 81 | let json_val = serde_json::to_value(yaml_val) 82 | .map_err(|e2| color_eyre::eyre::eyre!("Failed to convert YAML to JSON value: {}", e2))?; 83 | serde_json::from_value(json_val) 84 | .map_err(|e2| color_eyre::eyre::eyre!("Failed to parse StyleSet from JSON value (original YAML parse error: {e}): {e2}"))? 85 | } 86 | }; 87 | 88 | // Use name as identifier, or file name if name is empty 89 | let identifier = if style_set.name.is_empty() { 90 | file_path.file_stem() 91 | .and_then(|s| s.to_str()) 92 | .unwrap_or("unnamed") 93 | .to_string() 94 | } else { 95 | style_set.name.clone() 96 | }; 97 | 98 | self.style_sets.insert(identifier.clone(), style_set); 99 | Ok(identifier) 100 | } 101 | 102 | /// Save a style set to a YAML file 103 | pub fn save_to_file(&self, style_set: &StyleSet, file_path: &Path) -> Result<()> { 104 | // Ensure parent directory exists 105 | if let Some(parent) = file_path.parent() { 106 | fs::create_dir_all(parent)?; 107 | } 108 | 109 | // Serialize without YAML enum tags by round-tripping through JSON value 110 | let json_value = serde_json::to_value(style_set) 111 | .map_err(|e| color_eyre::eyre::eyre!("Failed to serialize StyleSet to JSON value: {}", e))?; 112 | let yaml = serde_yaml::to_string(&json_value) 113 | .map_err(|e| color_eyre::eyre::eyre!("Failed to serialize JSON value to YAML: {}", e))?; 114 | 115 | fs::write(file_path, yaml)?; 116 | Ok(()) 117 | } 118 | 119 | /// Enable a style set by identifier 120 | pub fn enable_style_set(&mut self, identifier: &str) -> bool { 121 | if self.style_sets.contains_key(identifier) { 122 | self.enabled_sets.insert(identifier.to_string()); 123 | true 124 | } else { 125 | false 126 | } 127 | } 128 | 129 | /// Disable a style set by identifier 130 | pub fn disable_style_set(&mut self, identifier: &str) { 131 | self.enabled_sets.remove(identifier); 132 | } 133 | 134 | /// Check if a style set is enabled 135 | pub fn is_enabled(&self, identifier: &str) -> bool { 136 | self.enabled_sets.contains(identifier) 137 | } 138 | 139 | /// Get all enabled style sets 140 | pub fn get_enabled_sets(&self) -> Vec<&StyleSet> { 141 | self.enabled_sets.iter() 142 | .filter_map(|id| self.style_sets.get(id)) 143 | .collect() 144 | } 145 | 146 | /// Get all style sets (enabled and disabled) 147 | pub fn get_all_sets(&self) -> Vec<(&String, &StyleSet, bool)> { 148 | self.style_sets.iter() 149 | .map(|(id, set)| (id, set, self.enabled_sets.contains(id))) 150 | .collect() 151 | } 152 | 153 | /// Get a style set by identifier 154 | pub fn get_set(&self, identifier: &str) -> Option<&StyleSet> { 155 | self.style_sets.get(identifier) 156 | } 157 | 158 | /// Add a new style set 159 | pub fn add_set(&mut self, style_set: StyleSet) -> String { 160 | let identifier = if style_set.name.is_empty() { 161 | format!("style_set_{}", self.style_sets.len()) 162 | } else { 163 | style_set.name.clone() 164 | }; 165 | self.style_sets.insert(identifier.clone(), style_set); 166 | identifier 167 | } 168 | 169 | /// Remove a style set 170 | pub fn remove_set(&mut self, identifier: &str) -> bool { 171 | self.enabled_sets.remove(identifier); 172 | self.style_sets.remove(identifier).is_some() 173 | } 174 | 175 | /// Get all loaded folder paths 176 | pub fn get_loaded_folders(&self) -> &[PathBuf] { 177 | &self.loaded_folders 178 | } 179 | 180 | /// Clear all style sets 181 | pub fn clear(&mut self) { 182 | self.style_sets.clear(); 183 | self.enabled_sets.clear(); 184 | self.loaded_folders.clear(); 185 | } 186 | 187 | /// Get enabled set identifiers (for serialization) 188 | pub fn get_enabled_identifiers(&self) -> Vec { 189 | self.enabled_sets.iter().cloned().collect() 190 | } 191 | 192 | /// Set enabled set identifiers (for deserialization) 193 | pub fn set_enabled_identifiers(&mut self, identifiers: Vec) { 194 | self.enabled_sets.clear(); 195 | for id in identifiers { 196 | if self.style_sets.contains_key(&id) { 197 | self.enabled_sets.insert(id); 198 | } 199 | } 200 | } 201 | 202 | /// Find StyleSets that match the given column names based on schema hints 203 | /// Returns a list of (identifier, StyleSet, confidence_score) sorted by confidence 204 | pub fn find_matching_sets(&self, columns: &[String]) -> Vec<(&String, &StyleSet, f32)> { 205 | let mut matches: Vec<_> = self.style_sets 206 | .iter() 207 | .filter_map(|(id, set)| { 208 | if let Some(ref hint) = set.schema_hint { 209 | let (score, _, _, _, _) = hint.calculate_confidence(columns); 210 | if score >= hint.min_confidence { 211 | Some((id, set, score)) 212 | } else { 213 | None 214 | } 215 | } else { 216 | None 217 | } 218 | }) 219 | .collect(); 220 | 221 | // Sort by confidence score (descending) 222 | matches.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); 223 | matches 224 | } 225 | 226 | /// Get suggested StyleSets for a dataset's columns 227 | /// Returns suggested sets that aren't already enabled 228 | pub fn get_suggestions(&self, columns: &[String]) -> Vec<(&String, &StyleSet, f32)> { 229 | self.find_matching_sets(columns) 230 | .into_iter() 231 | .filter(|(id, _, _)| !self.enabled_sets.contains(*id)) 232 | .collect() 233 | } 234 | 235 | /// Auto-enable matching StyleSets above the given confidence threshold 236 | pub fn auto_enable_matching(&mut self, columns: &[String], min_confidence: f32) -> Vec { 237 | let matching: Vec = self.style_sets 238 | .iter() 239 | .filter_map(|(id, set)| { 240 | if self.enabled_sets.contains(id) { 241 | return None; // Already enabled 242 | } 243 | if let Some(ref hint) = set.schema_hint { 244 | let (score, _, _, _, _) = hint.calculate_confidence(columns); 245 | if score >= min_confidence.max(hint.min_confidence) { 246 | Some(id.clone()) 247 | } else { 248 | None 249 | } 250 | } else { 251 | None 252 | } 253 | }) 254 | .collect(); 255 | 256 | for id in &matching { 257 | self.enabled_sets.insert(id.clone()); 258 | } 259 | 260 | matching 261 | } 262 | } 263 | 264 | impl Default for StyleSetManager { 265 | fn default() -> Self { 266 | Self::new() 267 | } 268 | } 269 | 270 | 271 | -------------------------------------------------------------------------------- /src/workspace.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{File, create_dir_all}; 2 | use std::path::Path; 3 | use serde::{Deserialize, Serialize}; 4 | use ratatui::style::{Color, Style}; 5 | use tui_textarea::TextArea; 6 | use polars::prelude::SerReader; 7 | use crate::dialog::sort_dialog::SortColumn; 8 | use crate::dialog::data_management_dialog::DataSource; 9 | use crate::dialog::project_settings_dialog::ProjectSettingsConfig; 10 | use crate::dialog::filter_dialog::FilterExpr; 11 | use crate::components::datatable_container::DataTableContainer; 12 | use crate::dialog::data_tab_manager_dialog::DataTabManagerDialog; 13 | use crate::dialog::column_width_dialog::ColumnWidthConfig; 14 | use crate::dialog::jmes_dialog::JmesPathKeyValuePair; 15 | use polars::prelude::ParquetReader; 16 | use tracing::info; 17 | 18 | // We surface errors via color-eyre; no custom error type needed. 19 | 20 | /// Serializable snapshot of dialogs/state we want to persist for a workspace 21 | #[derive(Debug, Clone, Serialize, Deserialize)] 22 | pub struct WorkspaceState { 23 | pub project: ProjectSettingsConfig, 24 | pub data_sources: Vec, 25 | // DataTableContainer dialog states (per active tab) are captured by index order 26 | pub tabs: Vec, 27 | // Enabled style set identifiers 28 | #[serde(default)] 29 | pub enabled_style_sets: Vec, 30 | } 31 | 32 | #[derive(Debug, Clone, Serialize, Deserialize)] 33 | pub struct TabState { 34 | pub dataset_id: String, 35 | // Stable identifiers to match tabs across sessions even if dataset_id changes 36 | pub source_file_path: Option, 37 | pub dataset_name: Option, 38 | pub sort: Vec, 39 | pub filter: Option, 40 | pub column_widths: ColumnWidthConfig, 41 | pub sql_query: String, 42 | pub jmes_expression: String, 43 | pub jmes_add_columns: Vec, 44 | // If current_df is materialized, a parquet file name stored under workspace/.datatui/tabs 45 | pub current_df_parquet: Option, 46 | } 47 | 48 | impl WorkspaceState { 49 | pub fn from_dialogs(manager: &DataTabManagerDialog) -> color_eyre::Result { 50 | // capture data_sources from data management dialog 51 | let data_sources: Vec = manager.data_management_dialog.data_sources.clone(); 52 | 53 | // capture tabs + dialog states 54 | let mut tabs: Vec = Vec::new(); 55 | for tab in &manager.tabs { 56 | let tab_id = tab.loaded_dataset.dataset.id.clone(); 57 | if let Some(container) = manager.containers.get(&tab_id) { 58 | tabs.push(Self::capture_tab_state(tab_id, container)); 59 | } else { 60 | // fallback: minimal 61 | tabs.push(TabState{ 62 | dataset_id: tab_id, 63 | source_file_path: None, 64 | dataset_name: None, 65 | sort: vec![], 66 | filter: None, 67 | column_widths: ColumnWidthConfig::default(), 68 | sql_query: String::new(), 69 | jmes_expression: String::new(), 70 | jmes_add_columns: vec![], 71 | current_df_parquet: None, 72 | }); 73 | } 74 | } 75 | 76 | Ok(Self { 77 | project: manager.project_settings_dialog.config.clone(), 78 | data_sources, 79 | tabs, 80 | enabled_style_sets: manager.style_set_manager.get_enabled_identifiers(), 81 | }) 82 | } 83 | 84 | fn capture_tab_state(dataset_id: String, container: &DataTableContainer) -> TabState { 85 | // Sort 86 | let sort = container.datatable.dataframe.last_sort.clone().unwrap_or_default(); 87 | // Filter 88 | let filter = container.datatable.dataframe.filter.clone(); 89 | // Stable identifiers 90 | let source_file_path = container 91 | .datatable 92 | .dataframe 93 | .metadata 94 | .source_path 95 | .as_ref() 96 | .map(|p| p.to_string_lossy().to_string()); 97 | let dataset_name = Some(container.datatable.dataframe.metadata.name.clone()); 98 | // Column widths 99 | let column_widths = container.datatable.dataframe.column_width_config.clone(); 100 | // SQL query (prefer recorded last_sql_query if available) 101 | let sql_query = container 102 | .datatable 103 | .dataframe 104 | .last_sql_query 105 | .clone() 106 | .unwrap_or_else(|| container.sql_dialog.textarea.lines().join("\n")); 107 | // JMES 108 | let (jmes_expression, jmes_add_columns) = { 109 | let _mode = &container.jmes_dialog.mode; 110 | ( 111 | container.jmes_dialog.textarea.lines().join("\n"), 112 | container.jmes_dialog.add_columns.clone(), 113 | ) 114 | }; 115 | // Parquet name filled in by save_to when we have a workspace path 116 | TabState { 117 | dataset_id, 118 | source_file_path, 119 | dataset_name, 120 | sort, 121 | filter, 122 | column_widths, 123 | sql_query, 124 | jmes_expression, 125 | jmes_add_columns, 126 | current_df_parquet: None 127 | } 128 | } 129 | 130 | pub fn save_to(&self, workspace_path: &Path) -> color_eyre::Result<()> { 131 | if !workspace_path.is_dir() { 132 | return Err(color_eyre::eyre::eyre!("Workspace path is not a directory: {}", workspace_path.display())); 133 | } 134 | // ensure folders and just write state JSON; parquet writing is handled by caller with access to containers 135 | create_dir_all(workspace_path)?; 136 | let file = File::create(workspace_path.join("datatui_workspace_state.json"))?; 137 | serde_json::to_writer_pretty(file, &self)?; 138 | Ok(()) 139 | } 140 | 141 | pub fn load_from(workspace_path: &Path) -> color_eyre::Result> { 142 | let file_path = workspace_path.join("datatui_workspace_state.json"); 143 | if !file_path.exists() { 144 | return Ok(None); 145 | } 146 | let file = File::open(file_path)?; 147 | let state: WorkspaceState = serde_json::from_reader(file)?; 148 | Ok(Some(state)) 149 | } 150 | 151 | pub fn apply_to(self, manager: &mut DataTabManagerDialog) -> color_eyre::Result<()> { 152 | // Apply project settings (workspace already known) 153 | manager.project_settings_dialog.config = self.project; 154 | 155 | // Extend data sources using a function 156 | manager.data_management_dialog.extend_data_sources(self.data_sources); 157 | 158 | // Rebuild tabs/containers from data sources 159 | manager.sync_tabs_from_data_management()?; 160 | 161 | // Apply per-tab states 162 | let parquet_root = if let Some(path) = manager.project_settings_dialog.config.workspace_path.as_ref() { 163 | path.join(".datatui").join("tabs") 164 | } else { 165 | // No workspace configured; skip parquet restores 166 | return Ok(()); 167 | }; 168 | 169 | for tab_state in self.tabs.into_iter() { 170 | // Try direct match by dataset_id 171 | let mut container_key: Option = if manager.containers.contains_key(&tab_state.dataset_id) { 172 | Some(tab_state.dataset_id.clone()) 173 | } else { 174 | None 175 | }; 176 | 177 | // If not found, attempt a stable match using source_file_path and dataset_name 178 | if container_key.is_none() 179 | && let (Some(saved_path), Some(saved_name)) = (&tab_state.source_file_path, &tab_state.dataset_name) 180 | { 181 | let found = manager 182 | .containers 183 | .iter() 184 | .find_map(|(key, c)| { 185 | let meta = &c.datatable.dataframe.metadata; 186 | let meta_path = meta 187 | .source_path 188 | .as_ref() 189 | .map(|p| p.to_string_lossy().to_string()); 190 | if meta_path.as_deref() == Some(saved_path.as_str()) && meta.name == *saved_name { 191 | Some(key.clone()) 192 | } else { 193 | None 194 | } 195 | }); 196 | container_key = found; 197 | } 198 | 199 | if let Some(key) = container_key 200 | && let Some(container) = manager.containers.get_mut(&key) 201 | { 202 | if !tab_state.sort.is_empty() { 203 | container.datatable.dataframe.last_sort = Some(tab_state.sort); 204 | } 205 | 206 | // column widths 207 | container.datatable.set_column_width_config(tab_state.column_widths.clone()); 208 | 209 | // sql 210 | container.sql_dialog.set_textarea_content(&tab_state.sql_query); 211 | 212 | // jmes dialog state 213 | container.jmes_dialog.add_columns = tab_state.jmes_add_columns.clone(); 214 | let jmes_lines: Vec = tab_state 215 | .jmes_expression 216 | .lines() 217 | .map(|s| s.to_string()) 218 | .collect::>(); 219 | container.jmes_dialog.textarea = TextArea::from(jmes_lines); 220 | container 221 | .jmes_dialog 222 | .textarea 223 | .set_line_number_style(Style::default().bg(Color::DarkGray)); 224 | 225 | // current_df parquet load if present (authoritative snapshot of current view) 226 | if let Some(fname) = &tab_state.current_df_parquet { 227 | let path = parquet_root.join(fname); 228 | if path.exists() { 229 | info!("Loading parquet file: {}", path.display()); 230 | let file = File::open(&path)?; 231 | if let Ok(df) = ParquetReader::new(file).finish() { 232 | container.datatable.set_current_df(df); 233 | } 234 | } 235 | } 236 | 237 | if let Some(f) = tab_state.filter.clone() { 238 | info!("Restoring filter: {:?}", f); 239 | container.set_filter_expression(f); 240 | } 241 | } 242 | } 243 | // Apply enabled style sets 244 | manager.style_set_manager.set_enabled_identifiers(self.enabled_style_sets); 245 | 246 | // After applying state, refresh active container so UI reflects latest data 247 | if let Some(container) = manager.get_active_container() { 248 | let _ = container.datatable.scroll_to_selection(); 249 | } 250 | Ok(()) 251 | } 252 | } 253 | 254 | 255 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Matthew Seyer 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DataTUI — A Terminal UI for Data 7 | 8 | 9 | 10 | 11 | 20 | 21 |
22 |
23 |
24 |
25 |

DataTUI

26 |

A Terminal UI for Data.

27 |

A fast, keyboard-first terminal UI for exploring CSV/TSV, Excel, SQLite, Parquet, and JSON with tabs, sorting, filtering, SQL (Polars), and more.

28 |
29 | Get Started 30 | View on GitHub 31 |
32 |
33 |
34 | DataTUI overview 35 |
36 |
37 |
38 | 39 |
40 |
41 |

Features

42 |
43 | 44 |
45 |
46 |
47 |

Broad import support

48 |

Guided dialogs for CSV/TSV, Excel, SQLite, Parquet, and JSON with type-aware options.

49 |
    50 |
  • Delimiter, header, quoting for CSV/TSV
  • 51 |
  • Sheet/table selection for Excel/SQLite
  • 52 |
  • Native Parquet/JSON flows
  • 53 |
54 |
55 |
Broad import support
56 |
57 |
58 | 59 | 60 |
61 |
62 |
63 |

Multi-column sort

64 |

Sort by multiple columns with clear header indicators and toggle behavior.

65 |
    66 |
  • Ascending/descending toggles
  • 67 |
  • Stable multi-key sorting
  • 68 |
  • Visual arrows and order badges
  • 69 |
70 |
71 |
Multi-column sort
72 |
73 |
74 | 75 | 76 |
77 |
78 |
79 |

Custom filter builder

80 |

Compose complex filters without writing code. Apply and clear against your base dataset.

81 |
    82 |
  • Variety of operators
  • 83 |
  • Export/Import filters
  • 84 |
  • Nested AND/OR conditions
  • 85 |
86 |
87 |
Custom filter builder
88 |
89 |
90 | 91 | 92 |
93 |
94 |
95 |

SQL on your data

96 |

Run SQL via Polars SQLContext, including custom UDFs for specialized logic.

97 |
    98 |
  • Register and use UDFs
  • 99 |
  • Blend with non-SQL workflows
  • 100 |
  • Fast in-memory execution
  • 101 |
102 |
103 |
SQL on your data
104 |
105 |
106 | 107 | 108 | 109 | 110 |
111 |
112 |
113 |

JMESPath transforms

114 |

Apply JMES expressions and add derived columns with a runtime preloaded with custom functions.

115 |
    116 |
  • Built-in plus custom functions
  • 117 |
  • Safe expression evaluation
  • 118 |
  • Composable with filters
  • 119 |
120 |
121 |
JMESPath transforms
122 |
123 |
124 | 125 | 126 |
127 |
128 |
129 |

Find and Find All

130 |

Regex/whole-word/case options, forward/back, wrap, and a contextual results list to jump to matches.

131 |
    132 |
  • Regex and normal modes
  • 133 |
  • Match context preview
  • 134 |
  • Jump to selection
  • 135 |
136 |
137 |
Find and Find All
138 |
139 |
140 | 141 | 142 |
143 |
144 |
145 |

DataFrame details

146 |

Inspect schema, shapes, and types at a glance with a dedicated details view.

147 |
    148 |
  • Rows, columns, and dtypes summary
  • 149 |
  • Per-column types and metadata
  • 150 |
  • Quick copy of schema
  • 151 |
  • Heatmap
  • 152 |
153 |
154 |
DataFrame details
155 |
156 |
157 | 158 | 159 |
160 |
161 |
162 |

Column width control

163 |

Auto-expand columns by content or set manual widths. Hide/show columns as needed.

164 |
    165 |
  • Auto or manual sizing
  • 166 |
  • Per-column overrides
  • 167 |
  • Hide/show visibility
  • 168 |
169 |
170 |
Column width control
171 |
172 |
173 | 174 | 175 |
176 |
177 |
178 |

Column operations

179 |

Reorder columns, cast types, and perform advanced operations including clustering flows.

180 |
    181 |
  • Generate embeddings
  • 182 |
  • Clustering options (k-means, DBSCAN)
  • 183 |
  • Principal Component Analysis
  • 184 |
  • Sory by prompt similarity
  • 185 |
186 |
187 |
Column operations
188 |
189 |
190 | 191 | 192 |
193 |
194 |
195 |

Similarity sort

196 |

Sort columns or rows based on their similarity to a natural-language prompt. Great for organizing by relevance or clustering related data.

197 |
    198 |
  • AI-powered semantic similarity to user-entered text
  • 199 |
  • Reorder data by closeness to your description or query
  • 200 |
  • Ideal for searching unstructured or textual columns
  • 201 |
202 |
203 |
Similarity sort
204 |
205 |
206 | 207 | 208 |
209 | 210 |
211 |
212 |

More features

213 |
214 |
215 |

Keyboard-first terminal UI

216 |

Navigate, search, sort, and filter without leaving the keyboard.

217 |
    218 |
  • Consistent shortcuts across dialogs
  • 219 |
  • High-FPS rendering path
  • 220 |
  • Minimal latency input handling
  • 221 |
222 |
223 |
224 |

Tabbed data views

225 |

Open multiple datasets at once and manage them with ease.

226 |
    227 |
  • Independent state per tab
  • 228 |
  • Quick switch and reordering
  • 229 |
  • Join multiple datasets with SQL
  • 230 |
231 |
232 |
233 |

Workspace persistence

234 |

Resume where you left off with saved state and snapshots.

235 |
    236 |
  • State and settings files
  • 237 |
  • Per-tab Parquet snapshots
  • 238 |
  • Instant startup on large datasets
  • 239 |
240 |
241 |
242 |

Polars-powered engine

243 |

Efficient transformations and fast rendering on large data.

244 |
    245 |
  • Lazy query plans
  • 246 |
  • Efficient collection to views
  • 247 |
  • Scales to millions of rows
  • 248 |
249 |
250 |
251 |

AI embeddings in SQL (optional)

252 |

Generate vector embeddings for text columns via an external provider.

253 |
    254 |
  • Opt-in integration
  • 255 |
  • Batch unique value optimization
  • 256 |
  • Great for semantic workflows
  • 257 |
258 |
259 |
260 |

Export data

261 |

Export current view or dataset with guided dialogs and defaults.

262 |
    263 |
  • Choose format and scope
  • 264 |
  • Preview before writing
  • 265 |
  • Fast I/O paths
  • 266 |
267 |
268 |
269 |

Project settings & data hub

270 |

Configure your workspace and manage sources from one place.

271 |
    272 |
  • Project defaults and paths
  • 273 |
  • Source browsing and selection
  • 274 |
  • One-click tab creation
  • 275 |
276 |
277 |
278 |
279 |
280 |
281 | 282 |
283 | 293 |
294 | 295 | 296 | 297 | 298 | 299 | 300 | -------------------------------------------------------------------------------- /src/dialog/alias_edit_dialog.rs: -------------------------------------------------------------------------------- 1 | //! AliasEditDialog: Simple dialog for editing a dataset alias 2 | 3 | use ratatui::prelude::*; 4 | use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap, BorderType}; 5 | use ratatui::text::{Span, Line}; 6 | use crate::action::Action; 7 | use crate::config::Config; 8 | use crate::tui::Event; 9 | use color_eyre::Result; 10 | use crossterm::event::{KeyEvent, MouseEvent, KeyCode}; 11 | use ratatui::Frame; 12 | use ratatui::layout::Size; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | use crate::components::Component; 15 | use crate::components::dialog_layout::split_dialog_area; 16 | 17 | /// AliasEditDialog: Simple dialog for editing a dataset alias 18 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 19 | pub struct AliasEditDialog { 20 | pub source_id: usize, 21 | pub dataset_id: String, 22 | pub dataset_name: String, 23 | pub current_alias: String, 24 | pub input_buffer: String, 25 | pub show_instructions: bool, 26 | pub cursor_index: usize, 27 | pub cursor_visible: bool, 28 | #[serde(skip)] 29 | pub config: Config, 30 | } 31 | 32 | impl AliasEditDialog { 33 | /// Create a new AliasEditDialog 34 | pub fn new(source_id: usize, dataset_id: String, dataset_name: String, current_alias: Option) -> Self { 35 | let current_alias = current_alias.unwrap_or_default(); 36 | let initial_len = current_alias.len(); 37 | Self { 38 | source_id, 39 | dataset_id, 40 | dataset_name, 41 | input_buffer: current_alias.clone(), 42 | current_alias, 43 | show_instructions: true, 44 | cursor_index: initial_len, 45 | cursor_visible: true, 46 | config: Config::default(), 47 | } 48 | } 49 | 50 | /// Render the dialog 51 | pub fn render(&self, area: Rect, buf: &mut Buffer) { 52 | // Clear the background for the popup 53 | Clear.render(area, buf); 54 | 55 | // Outer container with double border and title "Alias" 56 | let outer_block = Block::default() 57 | .title("Alias") 58 | .borders(Borders::ALL) 59 | .border_type(BorderType::Double); 60 | let outer_inner_area = outer_block.inner(area); 61 | outer_block.render(area, buf); 62 | 63 | let instructions = self.build_instructions_from_config(); 64 | let layout = split_dialog_area(outer_inner_area, self.show_instructions, 65 | if instructions.is_empty() { None } else { Some(instructions.as_str()) }); 66 | let content_area = layout.content_area; 67 | let instructions_area = layout.instructions_area; 68 | 69 | let content_block = Block::default() 70 | .title(format!("Edit Alias for: {}", self.dataset_name)) 71 | .borders(Borders::ALL); 72 | 73 | let content_inner_area = content_block.inner(content_area); 74 | content_block.render(content_area, buf); 75 | 76 | // Vertical layout: show current alias line, then new alias line directly below 77 | // Build lines with styled cursor character (black) so underlying letter is visible 78 | let current_line = format!( 79 | "Current alias: {}", 80 | if self.current_alias.is_empty() { "" } else { &self.current_alias } 81 | ); 82 | 83 | let mut new_alias_spans: Vec = Vec::new(); 84 | new_alias_spans.push(Span::raw("New alias: ")); 85 | let input_len = self.input_buffer.len(); 86 | let idx = self.cursor_index.min(input_len); 87 | if self.cursor_visible { 88 | if idx < input_len { 89 | // Split at byte index and style the char under the cursor 90 | let ch = self.input_buffer[idx..].chars().next().unwrap(); 91 | let ch_len = ch.len_utf8(); 92 | let prefix = &self.input_buffer[..idx]; 93 | let suffix = &self.input_buffer[idx + ch_len..]; 94 | if !prefix.is_empty() { 95 | new_alias_spans.push(Span::raw(prefix)); 96 | } 97 | new_alias_spans.push(Span::styled(ch.to_string(), Style::default().fg(Color::Black).bg(Color::White))); 98 | if !suffix.is_empty() { 99 | new_alias_spans.push(Span::raw(suffix)); 100 | } 101 | } else { 102 | // Cursor at end: show a black space to indicate position 103 | if !self.input_buffer.is_empty() { 104 | new_alias_spans.push(Span::raw(self.input_buffer.as_str())); 105 | } 106 | new_alias_spans.push(Span::styled(" ", Style::default().fg(Color::Black).bg(Color::White))); 107 | } 108 | } else { 109 | // Cursor hidden: render normally 110 | if !self.input_buffer.is_empty() { 111 | new_alias_spans.push(Span::raw(self.input_buffer.as_str())); 112 | } 113 | } 114 | 115 | let paragraph = Paragraph::new(vec![ 116 | Line::from(current_line), 117 | Line::from(new_alias_spans), 118 | ]) 119 | .style(Style::default().fg(Color::White)) 120 | .wrap(Wrap { trim: true }); 121 | paragraph.render(content_inner_area, buf); 122 | 123 | self.render_instructions(&instructions, instructions_area, buf); 124 | } 125 | 126 | fn render_instructions(&self, instructions: &str, instructions_area: Option, buf: &mut Buffer) { 127 | if self.show_instructions && let Some(instructions_area) = instructions_area { 128 | let instructions_paragraph = Paragraph::new(instructions) 129 | .block(Block::default().borders(Borders::ALL).title("Instructions")) 130 | .style(Style::default().fg(Color::Yellow)) 131 | .wrap(Wrap { trim: true }); 132 | instructions_paragraph.render(instructions_area, buf); 133 | } 134 | } 135 | 136 | /// Add a character to the input buffer 137 | pub fn add_char(&mut self, c: char) { 138 | let idx = self.cursor_index.min(self.input_buffer.len()); 139 | self.input_buffer.insert(idx, c); 140 | self.cursor_index = (idx + c.len_utf8()).min(self.input_buffer.len()); 141 | } 142 | 143 | /// Remove the last character from the input buffer 144 | pub fn backspace(&mut self) { 145 | if self.cursor_index > 0 { 146 | // Find previous char boundary 147 | let mut remove_at = self.cursor_index - 1; 148 | while !self.input_buffer.is_char_boundary(remove_at) { 149 | remove_at -= 1; 150 | } 151 | self.input_buffer.remove(remove_at); 152 | self.cursor_index = remove_at; 153 | } 154 | } 155 | 156 | /// Clear the input buffer 157 | pub fn clear(&mut self) { 158 | self.input_buffer.clear(); 159 | self.cursor_index = 0; 160 | } 161 | 162 | 163 | /// Get the current input as an alias (None if empty) 164 | pub fn get_alias(&self) -> Option { 165 | if self.input_buffer.trim().is_empty() { 166 | None 167 | } else { 168 | Some(self.input_buffer.trim().to_string()) 169 | } 170 | } 171 | 172 | /// Build instructions string from configured keybindings 173 | fn build_instructions_from_config(&self) -> String { 174 | self.config.actions_to_instructions(&[ 175 | (crate::config::Mode::Global, crate::action::Action::Enter), 176 | (crate::config::Mode::Global, crate::action::Action::Escape), 177 | (crate::config::Mode::AliasEdit, crate::action::Action::ClearText), 178 | ]) 179 | } 180 | } 181 | 182 | impl Component for AliasEditDialog { 183 | fn register_action_handler(&mut self, _tx: UnboundedSender) -> Result<()> { 184 | Ok(()) 185 | } 186 | 187 | fn register_config_handler(&mut self, _config: Config) -> Result<()> { 188 | self.config = _config; 189 | Ok(()) 190 | } 191 | 192 | fn init(&mut self, _area: Size) -> Result<()> { 193 | Ok(()) 194 | } 195 | 196 | fn handle_events(&mut self, event: Option) -> Result> { 197 | match event { 198 | Some(Event::Key(key)) => self.handle_key_event(key), 199 | Some(Event::Tick) => { 200 | // toggle cursor visibility on ticks for blink 201 | self.cursor_visible = !self.cursor_visible; 202 | Ok(None) 203 | } 204 | _ => Ok(None), 205 | } 206 | } 207 | 208 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 209 | if key.kind == crossterm::event::KeyEventKind::Press { 210 | // Handle Ctrl+I for instructions toggle if applicable 211 | 212 | // First, honor config-driven Global actions 213 | if let Some(global_action) = self.config.action_for_key(crate::config::Mode::Global, key) { 214 | match global_action { 215 | Action::Escape => return Ok(Some(Action::DialogClose)), 216 | Action::Enter => { 217 | // Save the alias 218 | let alias = self.get_alias(); 219 | return Ok(Some(Action::EditDatasetAlias { 220 | source_id: self.source_id, 221 | dataset_id: self.dataset_id.clone(), 222 | alias, 223 | })); 224 | } 225 | Action::Backspace => { 226 | self.backspace(); 227 | return Ok(None); 228 | } 229 | Action::Left => { 230 | if self.cursor_index > 0 { 231 | let mut new_idx = self.cursor_index - 1; 232 | while !self.input_buffer.is_char_boundary(new_idx) && new_idx > 0 { 233 | new_idx -= 1; 234 | } 235 | self.cursor_index = new_idx; 236 | } 237 | return Ok(None); 238 | } 239 | Action::Right => { 240 | if self.cursor_index < self.input_buffer.len() { 241 | let mut new_idx = self.cursor_index + 1; 242 | while new_idx < self.input_buffer.len() && !self.input_buffer.is_char_boundary(new_idx) { 243 | new_idx += 1; 244 | } 245 | self.cursor_index = new_idx.min(self.input_buffer.len()); 246 | } 247 | return Ok(None); 248 | } 249 | Action::ToggleInstructions => { 250 | self.show_instructions = !self.show_instructions; 251 | return Ok(None); 252 | } 253 | _ => {} 254 | } 255 | } 256 | 257 | // Next, check for dialog-specific actions 258 | if let Some(dialog_action) = self.config.action_for_key(crate::config::Mode::AliasEdit, key) { 259 | if dialog_action == Action::ClearText { 260 | self.clear(); 261 | return Ok(None); 262 | } 263 | } 264 | 265 | // Fallback for character input or other unhandled keys 266 | match key.code { 267 | KeyCode::Home => { 268 | self.cursor_index = 0; 269 | Ok(None) 270 | } 271 | KeyCode::End => { 272 | self.cursor_index = self.input_buffer.len(); 273 | Ok(None) 274 | } 275 | KeyCode::Char(c) => { 276 | self.add_char(c); 277 | Ok(None) 278 | } 279 | _ => Ok(None), 280 | } 281 | } else { 282 | Ok(None) 283 | } 284 | } 285 | 286 | fn handle_mouse_event(&mut self, _mouse: MouseEvent) -> Result> { 287 | Ok(None) 288 | } 289 | 290 | fn update(&mut self, _action: Action) -> Result> { 291 | Ok(None) 292 | } 293 | 294 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 295 | self.render(area, frame.buffer_mut()); 296 | Ok(()) 297 | } 298 | } 299 | 300 | #[cfg(test)] 301 | mod tests { 302 | use super::*; 303 | 304 | #[test] 305 | fn test_alias_edit_dialog_creation() { 306 | let dialog = AliasEditDialog::new( 307 | 1, 308 | "dataset_id".to_string(), 309 | "Test Dataset".to_string(), 310 | Some("Current Alias".to_string()) 311 | ); 312 | 313 | assert_eq!(dialog.source_id, 1); 314 | assert_eq!(dialog.dataset_id, "dataset_id"); 315 | assert_eq!(dialog.dataset_name, "Test Dataset"); 316 | assert_eq!(dialog.current_alias, "Current Alias"); 317 | assert_eq!(dialog.input_buffer, "Current Alias"); 318 | } 319 | 320 | #[test] 321 | fn test_alias_edit_dialog_no_alias() { 322 | let dialog = AliasEditDialog::new( 323 | 1, 324 | "dataset_id".to_string(), 325 | "Test Dataset".to_string(), 326 | None 327 | ); 328 | 329 | assert_eq!(dialog.current_alias, ""); 330 | assert_eq!(dialog.input_buffer, ""); 331 | } 332 | 333 | #[test] 334 | fn test_alias_edit_operations() { 335 | let mut dialog = AliasEditDialog::new( 336 | 1, 337 | "dataset_id".to_string(), 338 | "Test Dataset".to_string(), 339 | Some("Original".to_string()) 340 | ); 341 | 342 | // Test adding characters 343 | dialog.add_char('X'); 344 | assert_eq!(dialog.input_buffer, "OriginalX"); 345 | 346 | // Test backspace 347 | dialog.backspace(); 348 | assert_eq!(dialog.input_buffer, "Original"); 349 | 350 | // Test clear 351 | dialog.clear(); 352 | assert_eq!(dialog.input_buffer, ""); 353 | 354 | } 355 | 356 | #[test] 357 | fn test_get_alias() { 358 | let mut dialog = AliasEditDialog::new( 359 | 1, 360 | "dataset_id".to_string(), 361 | "Test Dataset".to_string(), 362 | None 363 | ); 364 | 365 | // Empty input should return None 366 | assert_eq!(dialog.get_alias(), None); 367 | 368 | // Whitespace-only input should return None 369 | dialog.input_buffer = " ".to_string(); 370 | assert_eq!(dialog.get_alias(), None); 371 | 372 | // Valid input should return Some with trimmed value 373 | dialog.input_buffer = " Valid Alias ".to_string(); 374 | assert_eq!(dialog.get_alias(), Some("Valid Alias".to_string())); 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/dialog/find_all_results_dialog.rs: -------------------------------------------------------------------------------- 1 | use ratatui::prelude::*; 2 | use ratatui::widgets::{Block, Borders, Table, Row, Cell, Clear, Paragraph, Wrap, BorderType}; 3 | use crate::components::dialog_layout::split_dialog_area; 4 | use crossterm::event::{KeyEvent, KeyEventKind}; 5 | use crate::action::Action; 6 | use crate::config::Config; 7 | 8 | #[derive(Debug)] 9 | pub struct FindAllResult { 10 | pub row: usize, 11 | pub column: String, 12 | pub context: String, 13 | } 14 | 15 | #[derive(Debug)] 16 | pub struct FindAllResultsDialog { 17 | pub results: Vec, 18 | pub selected: usize, 19 | pub show_instructions: bool, 20 | pub instructions: String, 21 | pub scroll_offset: usize, 22 | pub search_pattern: String, // Store the search pattern for 23 | pub visable_rows: usize, 24 | pub config: Config, 25 | } 26 | 27 | impl FindAllResultsDialog { 28 | pub fn new(results: Vec, instructions: String, search_pattern: String) -> Self { 29 | Self { 30 | results, 31 | selected: 0, 32 | show_instructions: true, 33 | instructions, 34 | scroll_offset: 0, 35 | search_pattern, 36 | visable_rows: 5, 37 | config: Config::default(), 38 | } 39 | } 40 | 41 | /// Register config handler 42 | pub fn register_config_handler(&mut self, _config: Config) -> color_eyre::Result<()> { 43 | self.config = _config; 44 | Ok(()) 45 | } 46 | 47 | /// Build instructions string from configured keybindings 48 | fn build_instructions_from_config(&self) -> String { 49 | self.config.actions_to_instructions(&[ 50 | (crate::config::Mode::Global, crate::action::Action::Escape), 51 | (crate::config::Mode::Global, crate::action::Action::Enter), 52 | (crate::config::Mode::Global, crate::action::Action::ToggleInstructions), 53 | (crate::config::Mode::FindAllResults, crate::action::Action::GoToFirst), 54 | (crate::config::Mode::FindAllResults, crate::action::Action::GoToLast), 55 | (crate::config::Mode::FindAllResults, crate::action::Action::PageUp), 56 | (crate::config::Mode::FindAllResults, crate::action::Action::PageDown), 57 | ]) 58 | } 59 | 60 | /// Render the dialog with a scrollable table of results 61 | pub fn render(&mut self, area: Rect, buf: &mut Buffer) { 62 | // Clear the entire area 63 | Clear.render(area, buf); 64 | 65 | // Outer container with double border 66 | let outer_block = Block::default() 67 | .title("Find") 68 | .borders(Borders::ALL) 69 | .border_type(BorderType::Double); 70 | let inner_area = outer_block.inner(area); 71 | outer_block.render(area, buf); 72 | 73 | let instructions = self.build_instructions_from_config(); 74 | let layout = split_dialog_area(inner_area, self.show_instructions, 75 | if instructions.is_empty() { None } else { Some(instructions.as_str()) }); 76 | let content_area = layout.content_area; 77 | let instructions_area = layout.instructions_area; 78 | 79 | // Render main content block 80 | let block = Block::default() 81 | .title("All Results") 82 | .borders(Borders::ALL); 83 | let all_results_area = block.inner(content_area); 84 | block.render(content_area, buf); 85 | 86 | if self.results.is_empty() { 87 | // Show "No results found" message 88 | let no_results = Paragraph::new("No matches found") 89 | .style(Style::default().fg(Color::Yellow)); 90 | no_results.render(all_results_area, buf); 91 | } else { 92 | // Render the results table with scroll bar 93 | self.render_results_table(all_results_area, buf); 94 | } 95 | 96 | // Render instructions area if available 97 | if let Some(instructions_area) = instructions_area { 98 | let instructions_paragraph = Paragraph::new(instructions.as_str()) 99 | .block(Block::default().borders(Borders::ALL).title("Instructions")) 100 | .style(Style::default().fg(Color::Yellow)) 101 | .wrap(Wrap { trim: true }); 102 | instructions_paragraph.render(instructions_area, buf); 103 | } 104 | } 105 | 106 | /// Ensure the selected row is within the visible viewport by adjusting the scroll offset 107 | fn update_scroll_offset(&mut self, visible_rows: usize) { 108 | let selected = self.selected; 109 | let total_items = self.results.len(); 110 | 111 | if selected < self.scroll_offset { 112 | self.scroll_offset = selected; 113 | } else if total_items > visible_rows && selected >= self.scroll_offset + visible_rows { 114 | // Scroll so that the selected item becomes the last visible row 115 | self.scroll_offset = selected + 1 - visible_rows; 116 | } 117 | } 118 | 119 | /// Render the scrollable results table with vertical scroll bar 120 | fn render_results_table(&mut self, area: Rect, buf: &mut Buffer) { 121 | // Define column widths (adjust as needed) 122 | let col_widths = [ 123 | Constraint::Length(8), // Row 124 | Constraint::Length(15), // Column 125 | Constraint::Min(20), // Context (flexible) 126 | ]; 127 | 128 | let max_rows = area.height.saturating_sub(1) as usize; 129 | self.visable_rows = max_rows; 130 | 131 | // Calculate visible range 132 | let start_idx = self.scroll_offset; 133 | let end_idx = (start_idx + max_rows).min(self.results.len()); 134 | 135 | // Draw scroll bar on the right side if needed 136 | let show_scroll_bar = self.results.len() > max_rows; 137 | let table_width = if show_scroll_bar { 138 | area.width.saturating_sub(1) // Leave space for scroll bar 139 | } else { 140 | area.width 141 | }; 142 | 143 | // Draw scroll bar if needed 144 | if show_scroll_bar { 145 | let scroll_bar_x = area.x + area.width.saturating_sub(1); 146 | let scroll_bar_height = max_rows; 147 | let scroll_bar_y_start = area.y; 148 | 149 | // Calculate thumb position and size 150 | let total_items = self.results.len(); 151 | let visible_items = max_rows; 152 | let thumb_size = std::cmp::max(1, (visible_items * visible_items) / total_items); 153 | let thumb_position = if total_items > visible_items { 154 | (self.scroll_offset * (visible_items - thumb_size)) / (total_items - visible_items) 155 | } else { 156 | 0 157 | }; 158 | 159 | // Draw scroll bar track 160 | for y in scroll_bar_y_start..scroll_bar_y_start + scroll_bar_height as u16 { 161 | buf.set_string(scroll_bar_x, y, "│", Style::default().fg(Color::DarkGray)); 162 | } 163 | 164 | // Draw scroll bar thumb 165 | let thumb_start = scroll_bar_y_start + thumb_position as u16; 166 | let thumb_end = (thumb_start + thumb_size as u16).min(scroll_bar_y_start + scroll_bar_height as u16); 167 | for y in thumb_start..thumb_end { 168 | buf.set_string(scroll_bar_x, y, "█", Style::default().fg(Color::Cyan)); 169 | } 170 | } 171 | 172 | // Create table rows from visible results 173 | let rows: Vec = self.results[start_idx..end_idx] 174 | .iter() 175 | .enumerate() 176 | .map(|(i, result)| { 177 | let row_idx = start_idx + i; 178 | let is_selected = row_idx == self.selected; 179 | let is_zebra = row_idx % 2 == 0; // Zebra striping 180 | 181 | // Remove newlines and carriage returns from the context 182 | // because in a Span they are treated as separate lines and it causes issues 183 | // with rendering the context in the dialog. 184 | let context_str = result.context 185 | .replace("\n", "") 186 | .replace("\r", ""); 187 | let highlighted_context = self.highlight_search_hit(&context_str); 188 | 189 | let mut style = Style::default(); 190 | if is_selected { 191 | style = style.fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD); 192 | } else if is_zebra { 193 | style = style.bg(Color::Rgb(30, 30, 30)); // Dark gray for zebra rows 194 | } 195 | 196 | Row::new(vec![ 197 | Cell::from(format!("{}", result.row)).style(style), 198 | Cell::from(result.column.clone()).style(style), 199 | Cell::from(highlighted_context).style(style), 200 | ]) 201 | }) 202 | .collect(); 203 | 204 | // Create and render the table with yellow header 205 | let table = Table::new(rows, col_widths) 206 | .header(Row::new(vec![ 207 | Cell::from("Row").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), 208 | Cell::from("Column").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), 209 | Cell::from("Context").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), 210 | ])) 211 | .column_spacing(1); 212 | 213 | // Render table in the adjusted area 214 | let table_area = Rect { 215 | x: area.x, 216 | y: area.y, 217 | width: table_width, 218 | height: area.height, 219 | }; 220 | 221 | ratatui::prelude::Widget::render(table, table_area, buf); 222 | } 223 | 224 | /// Highlight the search hit in the context string with yellow background 225 | fn highlight_search_hit(&self, context: &str) -> Line<'static> { 226 | if self.search_pattern.is_empty() { 227 | return Line::from(context.to_string()); 228 | } 229 | 230 | let mut spans = Vec::new(); 231 | let mut last_end = 0; 232 | 233 | // Find all occurrences of the search pattern (case-insensitive) 234 | let pattern_lower = self.search_pattern.to_lowercase(); 235 | let context_lower = context.to_lowercase(); 236 | 237 | // Simple substring matching for highlighting 238 | let mut pos = 0; 239 | while let Some(start) = context_lower[pos..].find(&pattern_lower) { 240 | let actual_start = pos + start; 241 | let actual_end = actual_start + self.search_pattern.len(); 242 | 243 | // Add text before the match 244 | if actual_start > last_end { 245 | spans.push(Span::raw(context[last_end..actual_start].to_string())); 246 | } 247 | 248 | // Add highlighted match 249 | let matched_style = Style::default() 250 | .bg(Color::Yellow) 251 | .fg(Color::Black) 252 | .add_modifier(Modifier::BOLD); 253 | spans.push(Span::styled( 254 | context[actual_start..actual_end].to_string(), 255 | matched_style 256 | )); 257 | 258 | last_end = actual_end; 259 | pos = actual_end; 260 | } 261 | 262 | // Add remaining text after the last match 263 | if last_end < context.len() { 264 | spans.push(Span::raw(context[last_end..].to_string())); 265 | } 266 | 267 | // If no matches found, return the original context 268 | if spans.is_empty() { 269 | Line::from(context.to_string()) 270 | } else { 271 | Line::from(spans) 272 | } 273 | } 274 | 275 | /// Handle keyboard events for navigation and actions 276 | pub fn handle_key_event(&mut self, key: KeyEvent) -> Option { 277 | let max_rows = self.visable_rows; 278 | 279 | if key.kind != KeyEventKind::Press { 280 | return None; 281 | } 282 | 283 | // First, honor config-driven Global actions 284 | if let Some(global_action) = self.config.action_for_key(crate::config::Mode::Global, key) { 285 | match global_action { 286 | Action::Escape => return Some(Action::DialogClose), 287 | Action::Enter => { 288 | // Go to the selected result in the main DataTable 289 | return self.results.get(self.selected).map(|result| Action::GoToResult { 290 | row: result.row, 291 | column: result.column.clone(), 292 | }); 293 | } 294 | Action::Up => { 295 | // Move selection up 296 | if self.selected > 0 { 297 | self.selected -= 1; 298 | // Adjust scroll if needed 299 | self.update_scroll_offset(max_rows); 300 | } 301 | return None; 302 | } 303 | Action::Down => { 304 | // Move selection down 305 | if self.selected < self.results.len().saturating_sub(1) { 306 | self.selected += 1; 307 | // Adjust scroll if needed 308 | self.update_scroll_offset(max_rows); 309 | } 310 | return None; 311 | } 312 | Action::ToggleInstructions => { 313 | self.show_instructions = !self.show_instructions; 314 | return None; 315 | } 316 | _ => {} 317 | } 318 | } 319 | 320 | // Next, check for FindAllResults-specific actions 321 | if let Some(dialog_action) = self.config.action_for_key(crate::config::Mode::FindAllResults, key) { 322 | match dialog_action { 323 | Action::GoToFirst => { 324 | // Go to first result 325 | self.selected = 0; 326 | self.scroll_offset = 0; 327 | return None; 328 | } 329 | Action::GoToLast => { 330 | // Go to last result 331 | self.selected = self.results.len().saturating_sub(1); 332 | // Adjust scroll to show the last result 333 | self.update_scroll_offset(max_rows); 334 | return None; 335 | } 336 | Action::PageUp => { 337 | // Page up navigation 338 | let page_size = max_rows.saturating_sub(1); 339 | if self.selected >= page_size { 340 | self.selected -= page_size; 341 | self.update_scroll_offset(max_rows); 342 | } else { 343 | self.selected = 0; 344 | self.scroll_offset = 0; 345 | } 346 | return None; 347 | } 348 | Action::PageDown => { 349 | // Page down navigation 350 | let page_size = max_rows.saturating_sub(1); 351 | let max_idx = self.results.len().saturating_sub(1); 352 | if self.selected + page_size <= max_idx { 353 | self.selected += page_size; 354 | } else { 355 | self.selected = max_idx; 356 | } 357 | // Adjust scroll if needed 358 | self.update_scroll_offset(max_rows); 359 | return None; 360 | } 361 | _ => {} 362 | } 363 | } 364 | 365 | None 366 | } 367 | 368 | /// Get the currently selected result 369 | pub fn get_selected_result(&self) -> Option<&FindAllResult> { 370 | self.results.get(self.selected) 371 | } 372 | 373 | /// Update the results (for persistence/reopening) 374 | pub fn update_results(&mut self, results: Vec) { 375 | self.results = results; 376 | self.selected = 0; 377 | self.scroll_offset = 0; 378 | } 379 | } -------------------------------------------------------------------------------- /src/action.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum::Display; 3 | use crate::dialog::sort_dialog::SortColumn; 4 | use crate::dialog::filter_dialog::{FilterExpr, ColumnFilter}; 5 | use crate::dialog::column_width_dialog::ColumnWidthConfig; 6 | use crate::dialog::find_dialog::{FindOptions, SearchMode}; 7 | use crate::dialog::TransformScope; 8 | use crate::dialog::jmes_dialog::JmesPathKeyValuePair; 9 | 10 | 11 | /// High-level actions that can be triggered by UI or components. 12 | #[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] 13 | pub enum Action { 14 | Tick, 15 | Render, 16 | Resize(u16, u16), 17 | Suspend, 18 | Resume, 19 | ClearScreen, 20 | Error(String), 21 | Help, 22 | /// Global actions (configurable) 23 | Quit, 24 | OpenKeybindings, 25 | Escape, 26 | Enter, 27 | Backspace, 28 | DeleteWord, 29 | Up, 30 | Down, 31 | Left, 32 | Right, 33 | Tab, 34 | Paste, 35 | /// DataManagementDialog actions (configurable) 36 | DeleteSelectedSource, 37 | LoadAllPendingDatasets, 38 | EditSelectedAlias, 39 | /// Open the Project Settings dialog 40 | OpenProjectSettingsDialog, 41 | /// Close the Project Settings dialog 42 | CloseProjectSettingsDialog, 43 | /// Open the Sort dialog in the current context 44 | OpenSortDialog, 45 | /// Quick sort: add/select current column in Sort dialog 46 | QuickSortCurrentColumn, 47 | /// Open the Filter dialog 48 | OpenFilterDialog, 49 | /// Quick filter: equals on current cell value 50 | QuickFilterEqualsCurrentValue, 51 | /// Move selected column left within the table 52 | MoveSelectedColumnLeft, 53 | /// Move selected column right within the table 54 | MoveSelectedColumnRight, 55 | /// Open SQL dialog 56 | OpenSqlDialog, 57 | /// Open JMESPath dialog 58 | OpenJmesDialog, 59 | /// Open Column Operations dialog 60 | OpenColumnOperationsDialog, 61 | /// Open Find dialog 62 | OpenFindDialog, 63 | /// Open DataFrame Details dialog 64 | OpenDataframeDetailsDialog, 65 | /// Open Column Width dialog 66 | OpenColumnWidthDialog, 67 | /// Open Data Export dialog 68 | OpenDataExportDialog, 69 | /// Copy currently selected cell 70 | CopySelectedCell, 71 | /// Toggle instructions panel 72 | ToggleInstructions, 73 | /// Open the Data Management dialog 74 | OpenDataManagementDialog, 75 | /// Close the Data Management dialog 76 | CloseDataManagementDialog, 77 | /// Move current tab to front 78 | MoveTabToFront, 79 | /// Move current tab to back 80 | MoveTabToBack, 81 | /// Move current tab one position left 82 | MoveTabLeft, 83 | /// Move current tab one position right 84 | MoveTabRight, 85 | /// Switch to previous tab 86 | PrevTab, 87 | /// Switch to next tab 88 | NextTab, 89 | /// Manually synchronize tabs from Data Management 90 | SyncTabs, 91 | /// Close any active dialog 92 | DialogClose, 93 | /// User applied a sort dialog with columns and directions 94 | SortDialogApplied(Vec), 95 | /// User applied a filter dialog with a root expression 96 | FilterDialogApplied(FilterExpr), 97 | /// Add a single filter condition programmatically (e.g., from Unique Values) 98 | AddFilterCondition(ColumnFilter), 99 | /// User applied a SQL dialog with a query string 100 | SqlDialogApplied(String), 101 | /// User applied a SQL dialog with a query string to create a new dataset 102 | SqlDialogAppliedNewDataset { 103 | dataset_name: String, 104 | dataframe: std::sync::Arc 105 | }, 106 | /// User requested to restore the original DataFrame from the SQL dialog 107 | SqlDialogRestore, 108 | /// User applied a column width dialog with configuration 109 | ColumnWidthDialogApplied(ColumnWidthConfig), 110 | /// User reordered columns in the column width dialog 111 | ColumnWidthDialogReordered(Vec), 112 | /// User requested to find next match in the DataTable 113 | FindNext { 114 | pattern: String, 115 | options: FindOptions, 116 | search_mode: SearchMode, 117 | }, 118 | /// User requested to count matches in the DataTable 119 | FindCount { 120 | pattern: String, 121 | options: FindOptions, 122 | search_mode: SearchMode, 123 | }, 124 | /// User requested to find all matches in the DataTable 125 | FindAll { 126 | pattern: String, 127 | options: FindOptions, 128 | search_mode: SearchMode, 129 | }, 130 | /// User requested to go to a specific result (row, column) from Find All results 131 | GoToResult { 132 | row: usize, 133 | column: String, 134 | }, 135 | /// User requested to remove a DataFrame from the manager 136 | RemoveDataFrame(usize), 137 | /// User requested to close the DataTable manager dialog 138 | CloseDataTableManagerDialog, 139 | /// User requested to open the DataTable manager dialog 140 | OpenDataTableManagerDialog, 141 | /// User requested to open the data import dialog 142 | OpenDataImportDialog, 143 | /// User requested to close the data import dialog 144 | CloseDataImportDialog, 145 | /// User requested to confirm data import 146 | ConfirmDataImport, 147 | /// User selected an item in DataImport dialog (e.g., proceed/open options) 148 | DataImportSelect, 149 | /// User requested to go back within the DataImport dialog 150 | DataImportBack, 151 | /// User requested to add a data import configuration 152 | AddDataImportConfig { 153 | config: crate::data_import_types::DataImportConfig, 154 | }, 155 | /// User requested to open file browser dialog 156 | OpenFileBrowserDialog, 157 | /// User requested to open file browser (generic) 158 | OpenFileBrowser, 159 | /// User requested to open CSV options dialog 160 | OpenCsvOptionsDialog, 161 | /// User requested to close CSV options dialog 162 | CloseCsvOptionsDialog, 163 | /// User requested to open XLSX options dialog 164 | OpenXlsxOptionsDialog, 165 | /// User requested to close XLSX options dialog 166 | CloseXlsxOptionsDialog, 167 | /// User requested to open SQLite options dialog 168 | OpenSqliteOptionsDialog, 169 | /// User requested to close SQLite options dialog 170 | CloseSqliteOptionsDialog, 171 | /// User requested to open Parquet options dialog 172 | OpenParquetOptionsDialog, 173 | /// Internal: start blocking import with gauge updates 174 | StartBlockingImport, 175 | /// User requested to close Parquet options dialog 176 | CloseParquetOptionsDialog, 177 | /// User requested to open JSON options dialog 178 | OpenJsonOptionsDialog, 179 | /// User requested to close JSON options dialog 180 | CloseJsonOptionsDialog, 181 | // (OpenDataExportDialog moved earlier in the enum) 182 | /// User requested to close data export dialog 183 | CloseDataExportDialog, 184 | /// User requested to remove a data source 185 | RemoveDataSource { source_id: usize }, 186 | /// User requested to open the data tab manager dialog 187 | OpenDataTabManagerDialog, 188 | /// User requested to close the data tab manager dialog 189 | CloseDataTabManagerDialog, 190 | /// User requested to add a dataset to the tab manager 191 | /// LoadDatasets{ 192 | /// datasets: Vec, 193 | /// } 194 | // Avoid carrying non-Send/Sync types in Action. 195 | // If needed, pass a serializable config instead of a full DataSource. 196 | AddDataSources { 197 | source_config: crate::data_import_types::DataImportConfig, 198 | }, 199 | /// User requested to edit a dataset alias 200 | EditDatasetAlias { 201 | source_id: usize, 202 | dataset_id: String, 203 | alias: Option, 204 | }, 205 | /// User applied project settings dialog with configuration 206 | ProjectSettingsApplied(crate::dialog::ProjectSettingsConfig), 207 | /// User requested to cast a column to a new dtype 208 | ColumnCastRequested { column: String, dtype: String }, 209 | /// Apply a JMESPath transformation to the dataset 210 | JmesTransformDataset((String, TransformScope)), 211 | /// Add columns to the dataset using JMESPath expressions per column name 212 | JmesTransformAddColumns(Vec, TransformScope), 213 | /// Request to persist the current workspace state 214 | SaveWorkspaceState, 215 | /// User requested a column operation from ColumnOperationsDialog 216 | ColumnOperationRequested(String), 217 | /// User applied column operation options 218 | ColumnOperationOptionsApplied(crate::dialog::column_operation_options_dialog::ColumnOperationConfig), 219 | /// User applied the embeddings prompt dialog with computed embedding 220 | EmbeddingsPromptDialogApplied { 221 | source_column: String, 222 | new_column_name: String, 223 | prompt_embedding: Vec, 224 | }, 225 | /// Open the Embeddings Prompt dialog directly (F1) 226 | OpenEmbeddingsPromptDialog, 227 | /// EmbeddingsPromptDialog requests to generate embeddings first 228 | EmbeddingsPromptDialogRequestGenerateEmbeddings { 229 | prompt_text: String, 230 | new_similarity_column: String, 231 | }, 232 | /// Internal container -> manager: open export dialog (alias already exists earlier) 233 | OpenDataExportDialogAlias, 234 | /// Sort dialog specific actions 235 | ToggleSortDirection, 236 | RemoveSortColumn, 237 | AddSortColumn, 238 | /// Filter dialog specific actions 239 | AddFilter, 240 | EditFilter, 241 | DeleteFilter, 242 | AddFilterGroup, 243 | SaveFilter, 244 | LoadFilter, 245 | ResetFilters, 246 | ToggleFilterGroupType, 247 | /// Find dialog specific actions 248 | ToggleSpace, 249 | Delete, 250 | /// JMESPath dialog specific actions 251 | AddColumn, 252 | EditColumn, 253 | DeleteColumn, 254 | ApplyTransform, 255 | /// FindAllResults dialog specific actions 256 | GoToFirst, 257 | GoToLast, 258 | PageUp, 259 | PageDown, 260 | /// SqlDialog specific actions 261 | SelectAllText, 262 | CopyText, 263 | RunQuery, 264 | CreateNewDataset, 265 | RestoreDataFrame, 266 | OpenSqlFileBrowser, 267 | ClearText, 268 | PasteText, 269 | /// XlsxOptionsDialog specific actions 270 | OpenXlsxFileBrowser, 271 | PasteFilePath, 272 | ToggleWorksheetLoad, 273 | /// ParquetOptionsDialog specific actions 274 | OpenParquetFileBrowser, 275 | PasteParquetFilePath, 276 | /// SqliteOptionsDialog specific actions 277 | OpenSqliteFileBrowser, 278 | ToggleImportAllTables, 279 | ToggleTableSelection, 280 | /// FileBrowserDialog specific actions 281 | FileBrowserPageUp, 282 | FileBrowserPageDown, 283 | ConfirmOverwrite, 284 | DenyOverwrite, 285 | NavigateToParent, 286 | /// ColumnWidthDialog specific actions 287 | ToggleAutoExpand, 288 | StartColumnEditing, 289 | ToggleEditMode, 290 | ToggleColumnHidden, 291 | MoveColumnUp, 292 | MoveColumnDown, 293 | /// JsonOptionsDialog specific actions 294 | OpenJsonFileBrowser, 295 | PasteJsonFilePath, 296 | ToggleNdjson, 297 | ToggleJsonAutodetect, 298 | FinishJsonImport, 299 | /// ColumnOperationOptionsDialog specific actions 300 | ToggleField, 301 | ToggleButtons, 302 | /// DataFrameDetailsDialog specific actions 303 | SwitchToNextTab, 304 | SwitchToPrevTab, 305 | ChangeColumnLeft, 306 | ChangeColumnRight, 307 | OpenSortChoice, 308 | OpenCastOverlay, 309 | AddFilterFromValue, 310 | ExportCurrentTab, 311 | NavigateHeatmapLeft, 312 | NavigateHeatmapRight, 313 | NavigateHeatmapUp, 314 | NavigateHeatmapDown, 315 | NavigateHeatmapPageUp, 316 | NavigateHeatmapPageDown, 317 | NavigateHeatmapHome, 318 | NavigateHeatmapEnd, 319 | ScrollStatsLeft, 320 | ScrollStatsRight, 321 | /// ProjectSettingsDialog specific actions 322 | ToggleDataViewerOption, 323 | /// DataExportDialog specific actions 324 | ToggleFormat, 325 | /// TableExportDialog specific actions 326 | CopyFilePath, 327 | ExportTable, 328 | /// DataExportDialog multi-dataset request 329 | DataExportRequestedMulti { 330 | dataset_ids: Vec, 331 | file_path: String, 332 | format_index: usize, 333 | }, 334 | /// KeybindingsDialog specific actions 335 | OpenGroupingDropdown, 336 | SelectNextGrouping, 337 | SelectPrevGrouping, 338 | StartRebinding, 339 | ConfirmRebinding, 340 | CancelRebinding, 341 | ClearBinding, 342 | SaveKeybindings, 343 | /// Reset all keybindings to defaults 344 | ResetKeybindings, 345 | /// Save keybindings to a chosen file path 346 | SaveKeybindingsAs, 347 | /// Open the LLM Client dialog 348 | OpenLlmClientDialog, 349 | /// Close the LLM Client dialog 350 | CloseLlmClientDialog, 351 | /// User applied LLM client dialog with configuration 352 | LlmClientDialogApplied(crate::dialog::llm_client_dialog::LlmConfig), 353 | /// User cancelled LLM client dialog 354 | LlmClientDialogCancel, 355 | /// Open the LLM Client Create dialog for embeddings 356 | OpenLlmClientCreateDialogEmbeddings, 357 | /// Open the LLM Client Create dialog for completion 358 | OpenLlmClientCreateDialogCompletion, 359 | /// Close the LLM Client Create dialog 360 | CloseLlmClientCreateDialog, 361 | /// User applied LLM client create dialog with selection 362 | LlmClientCreateDialogApplied(crate::dialog::llm_client_create_dialog::LlmClientSelection), 363 | /// Open the Style Set Manager dialog 364 | OpenStyleSetManagerDialog, 365 | /// Close the Style Set Manager dialog 366 | CloseStyleSetManagerDialog, 367 | /// Open the Style Rule Editor dialog 368 | OpenStyleRuleEditorDialog, 369 | /// Close the Style Rule Editor dialog 370 | CloseStyleRuleEditorDialog, 371 | /// Open the Style Set Browser dialog 372 | OpenStyleSetBrowserDialog, 373 | /// Close the Style Set Browser dialog 374 | CloseStyleSetBrowserDialog, 375 | /// User applied Style Set Manager dialog with enabled sets 376 | StyleSetManagerDialogApplied(Vec), // enabled style set identifiers 377 | /// User applied Style Rule Editor dialog with a rule 378 | StyleRuleEditorDialogApplied(crate::dialog::styling::StyleRule), 379 | /// User applied Style Set Browser dialog with imported sets 380 | StyleSetBrowserDialogApplied(Vec), // imported style set identifiers 381 | /// StyleSetManagerDialog specific actions 382 | AddStyleSet, 383 | RemoveStyleSet, 384 | ImportStyleSet, 385 | ExportStyleSet, 386 | DisableStyleSet, 387 | ToggleCategoryPanel, 388 | FocusCategoryTree, 389 | FocusStyleSetTable, 390 | EditStyleSet, 391 | /// StyleSetEditorDialog specific actions 392 | OpenStyleSetEditorDialog, 393 | CloseStyleSetEditorDialog, 394 | StyleSetEditorDialogApplied(crate::dialog::styling::StyleSet), 395 | AddStyleRule, 396 | EditStyleRule, 397 | DeleteStyleRule, 398 | MoveRuleUp, 399 | MoveRuleDown, 400 | SaveStyleSet, 401 | /// ApplicationScopeEditorDialog specific actions 402 | OpenApplicationScopeEditorDialog, 403 | CloseApplicationScopeEditorDialog, 404 | ApplicationScopeEditorDialogApplied(crate::dialog::styling::StyleApplication), 405 | ToggleScope, 406 | OpenForegroundColorPicker, 407 | OpenBackgroundColorPicker, 408 | ClearForeground, 409 | ClearBackground, 410 | ToggleModifier, 411 | /// ColorPickerDialog specific actions 412 | OpenColorPickerDialog, 413 | CloseColorPickerDialog, 414 | ColorPickerDialogApplied(Option), 415 | SelectNextColor, 416 | SelectPrevColor, 417 | } 418 | 419 | #[cfg(test)] 420 | mod tests { 421 | use super::*; 422 | use tracing::info; 423 | 424 | #[test] 425 | fn test_action_display() { 426 | let a1 = Action::DialogClose; 427 | let a2 = Action::SortDialogApplied(vec![SortColumn { name: "test".to_string(), ascending: true }]); 428 | let a1_str = format!("{a1}"); 429 | let a2_str = format!("{a2}"); 430 | info!("Action::DialogClose Display: {}", a1_str); 431 | info!("Action::SortDialogApplied Display: {}", a2_str); 432 | // Accept any non-empty string for now, or adjust to match actual output 433 | assert!(!a1_str.is_empty()); 434 | assert!(!a2_str.is_empty()); 435 | } 436 | 437 | #[test] 438 | fn test_variant_matching() { 439 | let action = Action::FilterDialogApplied(FilterExpr::And(vec![])); 440 | match action { 441 | Action::FilterDialogApplied(FilterExpr::And(_)) => { 442 | // Test passes if we match the And variant 443 | } 444 | _ => panic!("Expected FilterDialogApplied variant"), 445 | } 446 | } 447 | } 448 | --------------------------------------------------------------------------------