diff --git a/CHANGELOG.md b/CHANGELOG.md index a913c3619..594d2e73d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## [Unreleased] +### Added + +- `ggsql repl`, an interactive session: SQL statements print as tables and, + with the `window` feature, queries with a `VISUALISE` clause draw in a + persistent native window that updates with each new plot. Statements end + with `;`; the reader stays open for the whole session, so tables created by + one statement are visible to the next. + ### Changed - The reader/dialect layer was refactored around a central dialect registry with diff --git a/Cargo.lock b/Cargo.lock index 3cdb9db58..e19e29b11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -800,6 +800,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "clipper2-rust" version = "1.1.0" @@ -1321,6 +1330,12 @@ dependencies = [ "log", ] +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "equivalent" version = "1.0.2" @@ -1337,6 +1352,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "euclid" version = "0.22.14" @@ -1773,6 +1794,7 @@ dependencies = [ "ggsql", "png", "regex", + "rustyline", "serde_json", "termimad", "ureq", @@ -2007,8 +2029,6 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755ec8c5f3f62917c498d1fdbc6704dad9432c5f523696337895601342aa8c43" dependencies = [ "bytemuck", "clipper2-rust", @@ -2060,6 +2080,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -2957,6 +2986,27 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3883,6 +3933,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.6" @@ -4317,6 +4377,27 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "18.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "clipboard-win", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.2.2", + "utf8parse", + "windows-sys 0.61.2", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 457247270..4eec6f531 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,3 +95,7 @@ panic = "abort" [patch.crates-io] sqlite-wasm-rs = { git = "https://github.com/ggsql-dev/sqlite-wasm-rs.git", branch = "loadable-extensions" } +# Temporary, for the REPL plot window: builds against the local hephaestus +# checkout carrying the cross-thread window message API (posit-dev/hephaestus#49). +# Remove once a hephaestus release containing it is published. +hephaestus = { path = "../hephaestus" } diff --git a/doc/get_started/tooling/cli.qmd b/doc/get_started/tooling/cli.qmd index 4341af3d7..9a8da8164 100644 --- a/doc/get_started/tooling/cli.qmd +++ b/doc/get_started/tooling/cli.qmd @@ -249,6 +249,24 @@ Resizing the window re-lays-out the plot rather than stretching it, so labels an Like the raster writers, the viewer renders through the GPU and is not in every build. The subcommand exists either way and says what would enable it. +## Interactive sessions + +`ggsql repl` starts an interactive session in the terminal: + +```bash +ggsql repl +``` + +Statements end with `;`. A plain SQL statement prints its table; a query with a `VISUALISE` clause draws its plot in a native window that opens when the session starts and updates in place with each new plot. The database connection stays open for the whole session, so a table created by one statement is visible to the next: + +``` +ggsql> CREATE TABLE penguins AS SELECT * FROM 'penguins.csv'; +ggsql> SELECT species, COUNT(*) AS n FROM penguins GROUP BY species; +ggsql> SELECT * FROM penguins VISUALISE species AS fill DRAW bar; +``` + +History is kept across sessions, Ctrl-C abandons the statement being typed rather than the session, and `:quit` (or Ctrl-D) leaves — closing the window ends the session too. `:help` lists the commands. The session takes the same flags as `ggsql view`: `--reader` and `--cache` for the data source, and `-D` for the window's `width`, `height`, `background` and `title`. Like the viewer, the plot window renders through the GPU and is not in every build; the subcommand exists either way and says what would enable it. + ## Documentation The ggsql CLI has built-in documentation for ggsql syntax and usage. Run `ggsql docs` for an overview of available documentation topics, and `ggsql docs [topic]` to read about a specific topic. diff --git a/ggsql-cli/Cargo.toml b/ggsql-cli/Cargo.toml index 49b9f5904..5dc226e52 100644 --- a/ggsql-cli/Cargo.toml +++ b/ggsql-cli/Cargo.toml @@ -39,6 +39,7 @@ serde_json.workspace = true # Regex (used by `docs` markdown post-processing) regex.workspace = true +rustyline = "18.0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Terminal markdown rendering for `ggsql docs` (native targets only; diff --git a/ggsql-cli/src/main.rs b/ggsql-cli/src/main.rs index cf3c76354..8766170e9 100644 --- a/ggsql-cli/src/main.rs +++ b/ggsql-cli/src/main.rs @@ -13,6 +13,11 @@ use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use writers::{Output, WriterInfo}; +// The REPL only runs with the plot window, so it is compiled only there — +// and under `cfg(test)`, so its statement-logic tests run in every build. +#[cfg(any(feature = "window", test))] +mod repl; +mod table; mod writers; mod docs { @@ -204,6 +209,19 @@ pub enum Commands { view: ViewArgs, }, + /// Start an interactive session (a REPL) + /// + /// SQL statements print as tables; queries with a VISUALISE clause draw + /// their plot in a window that updates with each new plot. Statements end + /// with `;`. The reader stays open for the whole session, so tables + /// created in one statement are visible to the next. + /// + /// Requires the `window` feature and a working GPU adapter. + Repl { + #[command(flatten)] + view: ViewArgs, + }, + /// Parse a query and show the AST (for debugging) Parse { /// The ggsql query to parse @@ -298,6 +316,10 @@ fn main() -> anyhow::Result<()> { cmd_view(query, &view); } + Commands::Repl { view } => { + cmd_repl(&view); + } + Commands::Parse { query, format } => { cmd_parse(query, format); } @@ -545,6 +567,87 @@ fn cmd_view(query: String, args: &ViewArgs) { } } +/// Run an interactive session. +/// +/// The reader is opened once and lives for the whole session, which is what +/// makes one statement's tables visible to the next. The plot window's event +/// loop owns the main thread (the platform requires it) and the REPL loop +/// runs on a worker thread; finished `Spec`s cross to the window through its +/// message channel. +/// +/// The subcommand exists whether or not the feature does, so a build without +/// it says what would bring it back rather than dropping the command. +fn cmd_repl(args: &ViewArgs) { + #[cfg(feature = "window")] + { + use ggsql::writer::{PlotViewer, PlotWindowHandle}; + + let reader = + open_reader(&args.source.reader, args.source.cache.as_deref()).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + + if args.verbose { + eprintln!("Reader: {}", args.source.reader); + if let Some(ref cache) = args.source.cache { + eprintln!("Cache: {}", cache); + } + } + + let options = WriterOptions::parse(args.viewer_options.clone()).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + let viewer = PlotViewer::from_options(&options).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + let window = viewer.launch().unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + + struct WindowDisplay(PlotWindowHandle); + impl repl::PlotDisplay for WindowDisplay { + fn show(&self, spec: Spec) -> Result<(), String> { + self.0.show(spec).map_err(|e| e.to_string()) + } + fn is_closed(&self) -> bool { + self.0.is_closed() + } + fn shutdown(&self) { + self.0.shutdown(); + } + } + + let display = WindowDisplay(window.plot_handle()); + let verbose = args.verbose; + // The REPL thread is not joined: when the window closes, `run` + // returns below and the process exits, taking the thread blocked in + // readline with it. + std::thread::spawn(move || { + if let Err(e) = repl::run(reader.as_ref(), &display, verbose) { + eprintln!("REPL failed: {}", e); + repl::PlotDisplay::shutdown(&display); + } + }); + + // Blocks on the main thread until the window closes. + if let Err(e) = window.run() { + eprintln!("{}", e); + std::process::exit(1); + } + } + + #[cfg(not(feature = "window"))] + { + let _ = args; + eprintln!("The plot viewer is not compiled in. Rebuild with --features window"); + std::process::exit(1); + } +} + fn cmd_parse(query: String, format: String) { println!("Parsing query: {}", query); println!("Format: {}", format); @@ -620,63 +723,10 @@ fn print_table_fallback( max_rows: usize, output: Option<&std::path::Path>, ) { - let source_tree = match parser::SourceTree::new(query) { - Ok(st) => st, - Err(e) => { - eprintln!("Failed to parse query: {}", e); - std::process::exit(1); - } - }; - - let sql_part = source_tree.extract_sql().unwrap_or_default(); - - let data = reader.execute_sql(&sql_part); - if let Err(e) = data { - eprintln!("Failed to execute SQL query: {}", e); - std::process::exit(1) - } - let data = data.unwrap(); - - let nrow = data.height().min(max_rows); - let ncol = data.width(); - let colnames = data.get_column_names(); - - // We add an extra 'row' for the column names - let mut rows: Vec = vec![String::from(""); nrow + 1]; - - let columns = data.get_columns(); - for (col_id, (col_name, column_data)) in colnames.iter().zip(columns.iter()).enumerate() { - let mut width = col_name.chars().count(); - - // End last column without comma - let suffix = if col_id == ncol - 1 { "" } else { ", " }; - - // Prepopulate formatted column with column name - let mut col_fmt: Vec = vec![format!("{}{}", col_name, suffix)]; - - // Format every cell in column, tracking width - for row_idx in 0..nrow { - let cell = ggsql::array_util::value_to_string(column_data, row_idx); - let cell_fmt = format!("{}{}", cell, suffix); - let nchar = cell_fmt.chars().count(); - if nchar > width { - width = nchar; - } - col_fmt.push(cell_fmt); - } - // Pad strings with spaces - let col_fmt: Vec = col_fmt - .into_iter() - .map(|s| format!("{:width$}", s, width = width)) - .collect(); - - // Push columns to row string - for (row, fmt) in rows.iter_mut().zip(col_fmt.iter()) { - row.push_str(fmt.as_str()); - } - } - - let table = rows.join("\n"); + let table = table::format(query, reader, max_rows).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); match output { None => println!("{}", table), Some(path) => { diff --git a/ggsql-cli/src/repl.rs b/ggsql-cli/src/repl.rs new file mode 100644 index 000000000..202549b74 --- /dev/null +++ b/ggsql-cli/src/repl.rs @@ -0,0 +1,303 @@ +//! The interactive REPL behind `ggsql repl`. +//! +//! The loop itself is a thin rustyline shell; everything with logic in it — +//! statement completeness, classification, dispatch — is a free function so it +//! can be tested without a terminal. + +use ggsql::reader::{Reader, Spec}; +use ggsql::validate::validate; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; + +use crate::table; + +const PROMPT: &str = "ggsql> "; +const CONTINUATION_PROMPT: &str = " ...> "; +const MAX_TABLE_ROWS: usize = 100; + +/// What one submitted input means. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Statement { + /// Nothing but whitespace — ignore it. + Empty, + /// Leave the session. + Quit, + /// List the available commands. + Help, + /// A `:` command the REPL doesn't know. + Unknown(String), + /// A ggsql query, with the terminating `;` stripped. + Query(String), +} + +/// Where a finished plot goes. The window feature supplies the real +/// implementation; without it, and in tests, a stub explains the situation. +pub trait PlotDisplay { + fn show(&self, spec: Spec) -> Result<(), String>; + + /// Whether the user has closed the plot window. Always `false` for + /// displays that can't close. + fn is_closed(&self) -> bool { + false + } + + /// Tell the display the session is over, so it can close too. + fn shutdown(&self) {} +} + +/// Whether the accumulated input is ready to run. +/// +/// Meta-commands are complete on one line. Queries are complete when brackets +/// balance, no string is open, and the statement ends with `;` — matching how +/// SQL clients decide, and avoiding guesses about where a VISUALISE clause +/// ends. +pub fn is_complete(input: &str) -> bool { + let trimmed = input.trim(); + if trimmed.is_empty() { + return false; + } + if trimmed.starts_with(':') { + return true; + } + if !trimmed.ends_with(';') { + return false; + } + + let mut paren_depth = 0i32; + let mut bracket_depth = 0i32; + let mut in_string: Option = None; + let mut chars = trimmed.chars().peekable(); + + while let Some(c) = chars.next() { + match in_string { + Some(quote) if c == quote => { + // SQL escapes a quote inside a string by doubling it. + if chars.peek() == Some("e) { + chars.next(); + } else { + in_string = None; + } + } + Some(_) => {} + None => match c { + '\'' | '"' => in_string = Some(c), + '(' => paren_depth += 1, + ')' => paren_depth -= 1, + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + _ => {} + }, + } + } + + in_string.is_none() && paren_depth == 0 && bracket_depth == 0 +} + +/// Turn complete input into what to do with it. +pub fn classify(input: &str) -> Statement { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Statement::Empty; + } + if let Some(command) = trimmed.strip_prefix(':') { + return match command { + "quit" | "exit" | "q" => Statement::Quit, + "help" | "h" | "?" => Statement::Help, + other => Statement::Unknown(other.to_string()), + }; + } + // `is_complete` guarantees the trailing `;`; all that remains is to strip + // it so the parser never sees it. + let query = trimmed.trim_end_matches(';').trim().to_string(); + if query.is_empty() { + Statement::Empty + } else { + Statement::Query(query) + } +} + +/// Run one query, printing the table or handing the plot to `plots`. +/// +/// Errors are reported, not propagated: one bad statement must not end the +/// session. +pub fn execute_query(query: &str, reader: &dyn Reader, plots: &dyn PlotDisplay, verbose: bool) { + let validated = match validate(query) { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to validate query: {e}"); + return; + } + }; + if !validated.valid() { + eprintln!("Validation errors:"); + for err in validated.errors() { + eprintln!(" - {}", err.message); + } + return; + } + + if !validated.has_visual() { + match table::format(query, reader, MAX_TABLE_ROWS) { + Ok(table) => println!("{table}"), + Err(e) => eprintln!("{e}"), + } + return; + } + + let spec = match reader.execute(query) { + Ok(spec) => spec, + Err(e) => { + eprintln!("Failed to execute query: {e}"); + return; + } + }; + if verbose { + let metadata = spec.metadata(); + eprintln!("Rows: {}, layers: {}", metadata.rows, metadata.layer_count); + } + if let Err(e) = plots.show(spec) { + eprintln!("{e}"); + } +} + +fn print_help() { + println!("Commands:"); + println!(" :quit, :exit Leave the session (Ctrl-D works too)"); + println!(" :help Show this text"); + println!(); + println!("Statements end with ';'. A plain SQL statement prints as a table;"); + println!("a query with a VISUALISE clause draws in the plot window."); +} + +fn history_path() -> Option { + #[allow(deprecated)] + std::env::home_dir().map(|home| home.join(".ggsql_history")) +} + +/// Run the read-eval-print loop until the user leaves. +pub fn run(reader: &dyn Reader, plots: &dyn PlotDisplay, verbose: bool) -> rustyline::Result<()> { + let mut editor = DefaultEditor::new()?; + let history = history_path(); + if let Some(path) = &history { + let _ = editor.load_history(path); + } + + println!("ggsql REPL — end statements with ';'. :help for help, :quit to leave."); + + let mut buffer = String::new(); + loop { + let prompt = if buffer.trim().is_empty() { + PROMPT + } else { + CONTINUATION_PROMPT + }; + match editor.readline(prompt) { + Ok(line) => { + buffer.push_str(&line); + buffer.push('\n'); + if !is_complete(&buffer) { + continue; + } + if let Err(e) = editor.add_history_entry(buffer.trim()) { + eprintln!("warning: could not record history: {e}"); + } + let statement = classify(&buffer); + buffer.clear(); + match statement { + Statement::Empty => {} + Statement::Quit => break, + Statement::Help => print_help(), + Statement::Unknown(command) => { + eprintln!("Unknown command ':{command}'. :help lists the commands.") + } + Statement::Query(query) => execute_query(&query, reader, plots, verbose), + } + // The window closing is noticed here rather than instantly: + // rustyline is blocked on input, so the session ends after + // the next submitted line. + if plots.is_closed() { + println!("Plot window closed; ending the session."); + break; + } + } + // Ctrl-C abandons the current statement, not the session. + Err(ReadlineError::Interrupted) => { + buffer.clear(); + println!("^C"); + } + Err(ReadlineError::Eof) => break, + Err(e) => return Err(e), + } + } + + // The session is over; close the window if there is one. + plots.shutdown(); + + if let Some(path) = &history { + let _ = editor.save_history(path); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input_is_never_complete() { + assert!(!is_complete("")); + assert!(!is_complete(" \n ")); + assert_eq!(classify(" \n "), Statement::Empty); + } + + #[test] + fn queries_need_a_terminating_semicolon() { + assert!(!is_complete("SELECT 1")); + assert!(is_complete("SELECT 1;")); + assert!(!is_complete("SELECT 1; extra")); + } + + #[test] + fn brackets_and_strings_delay_completeness() { + assert!(!is_complete("SELECT (1 + 2;")); + assert!(is_complete("SELECT (1 + 2);")); + assert!(!is_complete("SELECT 'oops;")); + // A semicolon inside a string doesn't count as the terminator — but + // since the string is still open the statement is incomplete anyway, + // and once closed it needs a real `;`. + assert!(!is_complete("SELECT ';'")); + assert!(is_complete("SELECT ';';")); + } + + #[test] + fn doubled_quotes_stay_inside_the_string() { + assert!(!is_complete("SELECT 'it''s")); + assert!(is_complete("SELECT 'it''s';")); + } + + #[test] + fn meta_commands_are_complete_on_one_line() { + assert!(is_complete(":quit")); + assert_eq!(classify(":quit"), Statement::Quit); + assert_eq!(classify(":q"), Statement::Quit); + assert_eq!(classify(":exit"), Statement::Quit); + assert_eq!(classify(":help"), Statement::Help); + assert_eq!(classify(":h"), Statement::Help); + assert_eq!(classify(":bogus"), Statement::Unknown("bogus".to_string())); + } + + #[test] + fn classify_strips_the_terminator() { + assert_eq!( + classify("SELECT 1;\n"), + Statement::Query("SELECT 1".to_string()) + ); + assert_eq!( + classify("SELECT * FROM t VISUALISE DRAW point USING x AS x, y AS y;"), + Statement::Query( + "SELECT * FROM t VISUALISE DRAW point USING x AS x, y AS y".to_string() + ) + ); + assert_eq!(classify(";"), Statement::Empty); + } +} diff --git a/ggsql-cli/src/table.rs b/ggsql-cli/src/table.rs new file mode 100644 index 000000000..5da9329a4 --- /dev/null +++ b/ggsql-cli/src/table.rs @@ -0,0 +1,61 @@ +//! Aligned, CSV-like table formatting for query results with nothing to draw. +//! +//! Shared by `exec`/`run` (the no-VISUALISE fallback) and the REPL, so a plain +//! SQL statement looks the same however it was submitted. + +use ggsql::parser; +use ggsql::reader::Reader; + +/// Run `query`'s SQL part and format up to `max_rows` rows as an aligned, +/// CSV-like table. +pub fn format(query: &str, reader: &dyn Reader, max_rows: usize) -> Result { + let source_tree = + parser::SourceTree::new(query).map_err(|e| format!("Failed to parse query: {e}"))?; + + let sql_part = source_tree.extract_sql().unwrap_or_default(); + + let data = reader + .execute_sql(&sql_part) + .map_err(|e| format!("Failed to execute SQL query: {e}"))?; + + let nrow = data.height().min(max_rows); + let ncol = data.width(); + let colnames = data.get_column_names(); + + // We add an extra 'row' for the column names + let mut rows: Vec = vec![String::from(""); nrow + 1]; + + let columns = data.get_columns(); + for (col_id, (col_name, column_data)) in colnames.iter().zip(columns.iter()).enumerate() { + let mut width = col_name.chars().count(); + + // End last column without comma + let suffix = if col_id == ncol - 1 { "" } else { ", " }; + + // Prepopulate formatted column with column name + let mut col_fmt: Vec = vec![format!("{}{}", col_name, suffix)]; + + // Format every cell in column, tracking width + for row_idx in 0..nrow { + let cell = ggsql::array_util::value_to_string(column_data, row_idx); + let cell_fmt = format!("{}{}", cell, suffix); + let nchar = cell_fmt.chars().count(); + if nchar > width { + width = nchar; + } + col_fmt.push(cell_fmt); + } + // Pad strings with spaces + let col_fmt: Vec = col_fmt + .into_iter() + .map(|s| format!("{:width$}", s, width = width)) + .collect(); + + // Push columns to row string + for (row, fmt) in rows.iter_mut().zip(col_fmt.iter()) { + row.push_str(fmt.as_str()); + } + } + + Ok(rows.join("\n")) +} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 32720e656..76640990c 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -84,7 +84,7 @@ pub use tiff::{TiffCompression, TiffWriter}; #[cfg(feature = "webp")] pub use webp::WebpWriter; #[cfg(feature = "window")] -pub use window::PlotViewer; +pub use window::{PlotViewer, PlotWindowHandle, ReplWindow}; // Re-exported so a caller can name the setting without depending on the // renderer crate; the variants are the format's own vocabulary. diff --git a/src/writer/hephaestus/window.rs b/src/writer/hephaestus/window.rs index d2a56d62b..0868003db 100644 --- a/src/writer/hephaestus/window.rs +++ b/src/writer/hephaestus/window.rs @@ -4,8 +4,13 @@ //! composition every writer builds, and because `ggsql-cli` uses only public //! `ggsql::*` API and has no renderer dependency of its own. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + use hephaestus::plot::PlotComposition; -use hephaestus::window::{self, Event, EventCtx, Frame, WindowApp, WindowConfig}; +use hephaestus::window::{ + self, Event, EventCtx, Frame, MessageApp, WindowApp, WindowConfig, WindowHandle, WindowSender, +}; use super::canvas::{parse_background, whole_pixels}; use crate::reader::Spec; @@ -129,6 +134,28 @@ impl PlotViewer { window::run(config, SpecApp { view }) .map_err(|e| GgsqlError::WriterError(format!("the plot viewer failed: {e}"))) } + + /// Prepare a persistent window that shows each plot it is sent. + /// + /// Unlike [`show`](Self::show), nothing blocks yet: the returned + /// [`ReplWindow`] hands out a [`PlotWindowHandle`] for another thread to + /// send plots to, and its [`run`](ReplWindow::run) blocks on the main + /// thread until the window closes. That is the shape an interactive + /// session needs — queries execute on its thread, the window's event loop + /// keeps the main thread, and a winit event loop may only be created once + /// per process, so the window is opened once and reused. + pub fn launch(&self) -> Result { + let handle = WindowHandle::new() + .map_err(|e| GgsqlError::WriterError(format!("the plot viewer failed: {e}")))?; + let config = WindowConfig::new(self.title.clone()) + .size(self.width, self.height) + .background(self.background); + Ok(ReplWindow { + handle, + config, + closed: Arc::new(AtomicBool::new(false)), + }) + } } impl Default for PlotViewer { @@ -147,6 +174,125 @@ struct SpecApp { view: PlotComposition, } +/// What the session thread sends the window: a new plot to show, or the end +/// of the session. +/// +/// The `Spec` crosses rather than a `PlotComposition` so the window thread +/// does the composing — the plot types are single-threaded by design, and the +/// session thread's `Spec` is plain owned data. +enum ReplRequest { + Plot(Box), + Shutdown, +} + +/// A persistent plot window for an interactive session, not yet running. +/// +/// Created by [`PlotViewer::launch`]. Hand a [`PlotWindowHandle`] to the +/// session thread with [`plot_handle`](Self::plot_handle), then call +/// [`run`](Self::run) on the main thread. +pub struct ReplWindow { + handle: WindowHandle, + config: WindowConfig, + closed: Arc, +} + +impl ReplWindow { + /// The handle the session thread uses to reach the window. + pub fn plot_handle(&self) -> PlotWindowHandle { + PlotWindowHandle { + sender: self.handle.sender(), + closed: Arc::clone(&self.closed), + } + } + + /// Open the window and block until it closes. + /// + /// Must be called from the main thread, like [`PlotViewer::show`]. The + /// window starts empty; the first plot arrives when the session thread + /// sends one. + pub fn run(self) -> Result<()> { + let app = ReplApp { + current: None, + closed: Arc::clone(&self.closed), + }; + self.handle + .run(self.config, app) + .map_err(|e| GgsqlError::WriterError(format!("the plot viewer failed: {e}"))) + } +} + +/// The session thread's way to reach the plot window. +/// +/// `Send` and cheap to clone. `is_closed` flips when the user closes the +/// window, so the session can notice and end rather than keep accepting +/// queries nobody will see. +#[derive(Clone)] +pub struct PlotWindowHandle { + sender: WindowSender, + closed: Arc, +} + +impl PlotWindowHandle { + /// Show a plot in the window, replacing whatever it currently shows. + pub fn show(&self, spec: Spec) -> Result<()> { + self.sender + .send(ReplRequest::Plot(Box::new(spec))) + .map_err(|e| GgsqlError::WriterError(format!("could not reach the plot window: {e}"))) + } + + /// Ask the window to close, e.g. because the session ended. + /// + /// Ignores a dead channel: the window being gone already is the goal. + pub fn shutdown(&self) { + let _ = self.sender.send(ReplRequest::Shutdown); + } + + /// Whether the user has closed the window. + pub fn is_closed(&self) -> bool { + self.closed.load(Ordering::Relaxed) + } +} + +/// The window-side app for an interactive session: shows the latest plot it +/// was sent, records the window closing so the session thread can notice. +struct ReplApp { + current: Option, + closed: Arc, +} + +impl WindowApp for ReplApp { + fn draw(&mut self, frame: &mut Frame<'_>) { + let Some(view) = self.current.as_mut() else { + // No plot yet: the frame stays at the background color. + return; + }; + let (scene, size, dpi) = frame.parts(); + view.render(scene, size, dpi); + } + + fn event(&mut self, ctx: &mut EventCtx<'_>, event: Event) { + if matches!(event, Event::CloseRequested) { + self.closed.store(true, Ordering::Relaxed); + ctx.exit(); + } + } +} + +impl MessageApp for ReplApp { + fn message(&mut self, ctx: &mut EventCtx<'_>, message: ReplRequest) { + match message { + // hephaestus requests a redraw after every message, so a new + // composition is on screen at the next frame with nothing more + // to do here. + ReplRequest::Plot(spec) => match super::compose::prepare(spec.plot(), spec.data()) { + Ok(view) => self.current = Some(view), + Err(e) => eprintln!("Failed to compose the plot: {e}"), + }, + ReplRequest::Shutdown => ctx.exit(), + } + } +} + impl WindowApp for SpecApp { fn draw(&mut self, frame: &mut Frame<'_>) { // The frame reports its own size and dpi, which is what makes a resize diff --git a/src/writer/mod.rs b/src/writer/mod.rs index 02d235a2b..b3a042992 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -87,7 +87,7 @@ pub use hephaestus::SvgWriter; // Not a writer — it produces no output — but it needs the same composition, so // it lives beside them. See its own docs for why it is not a `Writer` impl. #[cfg(feature = "window")] -pub use hephaestus::PlotViewer; +pub use hephaestus::{PlotViewer, PlotWindowHandle, ReplWindow}; #[cfg(feature = "png")] pub use hephaestus::{PngCompression, PngWriter}; #[cfg(feature = "tiff")]