From 84c1c68d25c6ec10542752f7a6556b656024c896 Mon Sep 17 00:00:00 2001 From: YPARK Date: Mon, 5 Oct 2026 19:52:19 -0700 Subject: [PATCH 1/2] run_screen: opt-in mouse, reported chords and notices; version 0.8.1 Screen gains default methods a view opts into: takes_mouse and mouse for clicks and the wheel, reports_chords for keys held with Shift, Alt or Ctrl told apart from the bare key, and take_notice for a bell and a desktop notification. run_screen asks the terminal for these only for a screen that wants them, takes them back while its screen is up, and takes events already queued before drawing again. Existing screens are unchanged. Also clears lints raised by the current clippy. --- Cargo.toml | 2 +- src/alg/collapse_data/stats.rs | 2 +- src/aux/ontology.rs | 8 +- src/handlers/builders/from_fragments.rs | 6 +- src/handlers/builders/from_mtx.rs | 2 +- src/handlers/merging.rs | 24 ++--- src/handlers/transformation/squeeze.rs | 4 +- src/handlers/transformation/subset.rs | 4 +- src/hdf5_io.rs | 4 +- src/interactive/ui.rs | 117 +++++++++++++++++++++--- src/sim/handlers.rs | 6 +- src/sparse_io_vector/batch.rs | 4 +- src/sparse_io_vector/matched.rs | 6 +- tests/sparse_zarr_backend_tests.rs | 2 +- tests/streaming_write_tests.rs | 2 +- 15 files changed, 143 insertions(+), 50 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1d86a3e..9462681 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" readme = "README.md" # Own version (decoupled from workspace.version): data-beans evolves its CLI / # QC API independently of the shared utility crates. -version = "0.8.0" +version = "0.8.1" rust-version = "1.91" # Ship only the sources, README and license: nothing else in the working tree # (data stores, saved name lists, scratch files) can end up on crates.io. diff --git a/src/alg/collapse_data/stats.rs b/src/alg/collapse_data/stats.rs index 0967289..032d67e 100644 --- a/src/alg/collapse_data/stats.rs +++ b/src/alg/collapse_data/stats.rs @@ -344,7 +344,7 @@ fn optimize_block( for _opt_iter in 0..num_iter { #[cfg(debug_assertions)] { - debug!("iteration: {}", &_opt_iter); + debug!("iteration: {}", _opt_iter); } update_mu(&mut mu_adj_param, &delta_gb); diff --git a/src/aux/ontology.rs b/src/aux/ontology.rs index 837e6b8..6fcda1c 100644 --- a/src/aux/ontology.rs +++ b/src/aux/ontology.rs @@ -83,10 +83,10 @@ impl Ontology { } TermClause::IsObsolete(b) => obsolete = obsolete || *b, TermClause::IsA(parent) => is_a.push(parent.to_string().trim().into()), - TermClause::Relationship(rel, target) => { - if rel.to_string().trim() == "part_of" { - part_of.push(target.to_string().trim().into()); - } + TermClause::Relationship(rel, target) + if rel.to_string().trim() == "part_of" => + { + part_of.push(target.to_string().trim().into()); } _ => {} } diff --git a/src/handlers/builders/from_fragments.rs b/src/handlers/builders/from_fragments.rs index d36871f..9a22b96 100644 --- a/src/handlers/builders/from_fragments.rs +++ b/src/handlers/builders/from_fragments.rs @@ -131,7 +131,7 @@ pub fn run_build_from_fragments(args: &FromFragmentsArgs) -> anyhow::Result<()> resolve_backend_file(&effective_output, Some(args.backend.clone()))?; if std::path::Path::new(backend_file.as_ref()).exists() { - info!("Removing existing backend file: {}", &backend_file); + info!("Removing existing backend file: {}", backend_file); remove_file(&backend_file)?; } @@ -1110,8 +1110,8 @@ mod tests { /////////////////////////////////////////////////////////////// fn run_pair_and_compare(args_serial: FromFragmentsArgs, args_parallel: FromFragmentsArgs) { - let out_serial = format!("{}.zarr", &args_serial.output); - let out_parallel = format!("{}.zarr", &args_parallel.output); + let out_serial = format!("{}.zarr", args_serial.output); + let out_parallel = format!("{}.zarr", args_parallel.output); run_build_from_fragments(&args_serial).unwrap(); run_build_from_fragments(&args_parallel).unwrap(); let a = open_sparse_matrix(&out_serial, &SparseIoBackend::Zarr).unwrap(); diff --git a/src/handlers/builders/from_mtx.rs b/src/handlers/builders/from_mtx.rs index 2a3220b..291705b 100644 --- a/src/handlers/builders/from_mtx.rs +++ b/src/handlers/builders/from_mtx.rs @@ -92,7 +92,7 @@ pub fn run_build_from_mtx(args: &FromMtxArgs) -> anyhow::Result<()> { if std::path::Path::new(backend_file.as_ref()).exists() { info!( "This existing backend file '{}' will be deleted", - &backend_file + backend_file ); remove_file(&backend_file)?; } diff --git a/src/handlers/merging.rs b/src/handlers/merging.rs index 708fc60..a8fe370 100644 --- a/src/handlers/merging.rs +++ b/src/handlers/merging.rs @@ -342,7 +342,7 @@ pub fn run_merge_backend(args: &MergeBackendArgs) -> anyhow::Result<()> { if std::path::Path::new(backend_file.as_ref()).exists() { info!( "This existing backend file '{}' will be deleted", - &backend_file + backend_file ); remove_file(&backend_file)?; } @@ -398,7 +398,7 @@ pub fn run_merge_backend(args: &MergeBackendArgs) -> anyhow::Result<()> { info!( "Successfully created a sparse backend file: {}", - &backend_file + backend_file ); let batch_map = column_names @@ -407,7 +407,7 @@ pub fn run_merge_backend(args: &MergeBackendArgs) -> anyhow::Result<()> { .collect::>(); if args.do_squeeze { - info!("Squeeze the backend data {}", &backend_file); + info!("Squeeze the backend data {}", backend_file); let squeeze_args = RunSqueezeArgs { // Squeezing in place on a path the caller already resolved: the // zip decision was made there, so nothing is re-zipped here. @@ -459,7 +459,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { if let Some(base) = std::path::Path::new(&dir).file_stem() { let base = base.to_str().expect("invalid base name").to_string(); - info!("Searching relevant files within: {}", &base); + info!("Searching relevant files within: {}", base); let batch_name = Some(base); if let Ok(this_dir) = std::fs::read_dir(&dir) { @@ -482,7 +482,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { } if let (Some(m), Some(r), Some(c), Some(b)) = (mtx, row, col, batch_name) { - info!("Build {} from {}, {}, {} ", &b, &m, &r, &c); + info!("Build {} from {}, {}, {} ", b, m, r, c); mtx_files.push(m); row_files.push(r); col_files.push(c); @@ -491,7 +491,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { } } - info!("Searching subdir within: {}", &dir); + info!("Searching subdir within: {}", dir); let mut sub_dir_vec = std::fs::read_dir(&dir)? .filter_map(Result::ok) @@ -507,7 +507,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { if let Some(base) = std::path::Path::new(&sub_dir).file_stem() { let base = base.to_str().expect("invalid base name").to_string(); - info!("searching {} ...", &base); + info!("searching {} ...", base); let batch_name = Some(base); @@ -516,7 +516,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { if let Some(_path) = x?.path().to_str() { let _path = _path.to_string(); - info!("Found: {}", &_path); + info!("Found: {}", _path); if _path.ends_with(args.mtx_file_name.as_ref()) { mtx = Some(_path.into_boxed_str()); @@ -530,7 +530,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { } if let (Some(m), Some(r), Some(c), Some(b)) = (mtx, row, col, batch_name) { - info!("Build {} from {}, {}, {} ", &b, &m, &r, &c); + info!("Build {} from {}, {}, {} ", b, m, r, c); mtx_files.push(m); row_files.push(r); col_files.push(c); @@ -655,7 +655,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { if std::path::Path::new(&backend_file).exists() { info!( "This existing backend file '{}' will be deleted", - &backend_file + backend_file ); remove_file(&backend_file)?; } @@ -701,7 +701,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { info!( "Successfully created a sparse backend file: {}", - &backend_file + backend_file ); let batch_map = column_names @@ -710,7 +710,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { .collect::>(); if args.do_squeeze { - info!("Squeeze the backend data {}", &backend_file); + info!("Squeeze the backend data {}", backend_file); let squeeze_args = RunSqueezeArgs { // Squeezing in place on a path the caller already resolved: the // zip decision was made there, so nothing is re-zipped here. diff --git a/src/handlers/transformation/squeeze.rs b/src/handlers/transformation/squeeze.rs index af3e3d2..4f125d6 100644 --- a/src/handlers/transformation/squeeze.rs +++ b/src/handlers/transformation/squeeze.rs @@ -273,7 +273,7 @@ fn run_merge_then_squeeze( let (backend, backend_file) = resolve_backend_file(&effective_output, Some(backend))?; if std::path::Path::new(backend_file.as_ref()).exists() { - info!("Removing existing output file: {}", &backend_file); + info!("Removing existing output file: {}", backend_file); remove_file(&backend_file)?; } @@ -289,7 +289,7 @@ fn run_merge_then_squeeze( merged_data.register_column_names_vec(&column_names); merged_data.set_metadata(&meta::common_metadata(&metas))?; - info!("Created merged file: {}", &backend_file); + info!("Created merged file: {}", backend_file); // Step 4: Squeeze the merged result info!( diff --git a/src/handlers/transformation/subset.rs b/src/handlers/transformation/subset.rs index a64b142..9ab4cf1 100644 --- a/src/handlers/transformation/subset.rs +++ b/src/handlers/transformation/subset.rs @@ -168,7 +168,7 @@ pub fn subset_columns(args: &SubsetColumnsArgs) -> anyhow::Result<()> { drop(data); if args.do_squeeze { - info!("Squeeze the backend data {}", &output_file); + info!("Squeeze the backend data {}", output_file); let squeeze_args = build_squeeze_args( output_file.clone(), args.column_nnz_cutoff, @@ -270,7 +270,7 @@ pub fn subset_rows(args: &SubsetRowsArgs) -> anyhow::Result<()> { drop(data); if args.do_squeeze { - info!("Squeeze the backend data {}", &output_file); + info!("Squeeze the backend data {}", output_file); let squeeze_args = build_squeeze_args( output_file.clone(), args.column_nnz_cutoff, diff --git a/src/hdf5_io.rs b/src/hdf5_io.rs index be6b7d6..8de1c91 100644 --- a/src/hdf5_io.rs +++ b/src/hdf5_io.rs @@ -86,8 +86,8 @@ pub fn resolve_backend_file( `hdf5` feature; writing Zarr instead ({} -> {}). Pass \ `--zip=true` (or use a `.zarr.zip` output path) for a zipped \ archive.", - &backend_file, - &new_path + backend_file, + new_path ); return Ok((SparseIoBackend::Zarr, new_path.into_boxed_str())); } diff --git a/src/interactive/ui.rs b/src/interactive/ui.rs index 423a625..a592711 100644 --- a/src/interactive/ui.rs +++ b/src/interactive/ui.rs @@ -3,7 +3,11 @@ //! plot with a y gutter, an x axis, and markers. use ratatui::buffer::Buffer; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, + KeyModifiers, KeyboardEnhancementFlags, MouseButton, MouseEvent, MouseEventKind, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -44,6 +48,56 @@ pub trait Screen { fn tick(&mut self) -> bool { false } + /// Whether the view takes the mouse: only then is the terminal asked for + /// it, since a view holding it keeps text from being selected. + fn takes_mouse(&self) -> bool { + false + } + /// A left click or a turn of the wheel, at a cell of the screen. + fn mouse(&mut self, _event: MouseEvent) {} + /// Whether the terminal is asked to report keys held with Shift, Alt or + /// Ctrl apart (the kitty keyboard protocol; others ignore the request), + /// so that Shift+Enter, say, is not taken for Enter. + fn reports_chords(&self) -> bool { + false + } + /// Something to tell whoever is away, once: [`run_screen`] rings the + /// terminal's bell and asks it for a desktop notification saying it. + fn take_notice(&mut self) -> Option { + None + } +} + +/// The terminal modes a screen asked for, taken back while its screen is +/// still up: terminals keep each screen's modes apart. +#[derive(Default)] +struct Modes { + mouse: bool, + chords: bool, +} + +impl Modes { + fn ask(&mut self, screen: &impl Screen) { + let mut out = std::io::stdout(); + if screen.takes_mouse() && !self.mouse { + self.mouse = ratatui::crossterm::execute!(out, EnableMouseCapture).is_ok(); + } + if screen.reports_chords() && !self.chords { + let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES; + self.chords = + ratatui::crossterm::execute!(out, PushKeyboardEnhancementFlags(flags)).is_ok(); + } + } + + fn release(&mut self) { + let mut out = std::io::stdout(); + if std::mem::take(&mut self.mouse) { + let _ = ratatui::crossterm::execute!(out, DisableMouseCapture); + } + if std::mem::take(&mut self.chords) { + let _ = ratatui::crossterm::execute!(out, PopKeyboardEnhancementFlags); + } + } } /// How long [`run_screen`] waits for a key before asking [`Screen::tick`]. @@ -69,39 +123,80 @@ impl Drop for HeldLogs { /// return and on panic. Log records raised meanwhile are held back and /// written once the normal screen is back. Blocking work runs on the normal /// screen, where its own progress output belongs, and the view comes back -/// after it. +/// after it. The mouse and keys held with modifiers are reported only to a +/// screen that asks for them, and events already queued (a turn of the wheel +/// is several) are taken before the screen is drawn again. pub fn run_screen(screen: &mut impl Screen) -> anyhow::Result<()> { ratatui::run(|terminal| -> anyhow::Result<()> { let held = HeldLogs::new(); + let mut modes = Modes::default(); let mut redraw = true; while !screen.done() { if redraw { terminal.draw(|f| screen.render(f))?; + // Asked once the screen is up: its modes are its own. + modes.ask(screen); + } + if let Some(notice) = screen.take_notice() { + // A bell, and a desktop notification where the terminal shows + // one (OSC 9); terminals without either ignore them. + let mut out = std::io::stdout(); + let _ = std::io::Write::write_all( + &mut out, + format!("\x07\x1b]9;{notice}\x07").as_bytes(), + ); + let _ = std::io::Write::flush(&mut out); } if !event::poll(TICK)? { redraw = screen.tick(); continue; } - redraw = true; - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { - continue; - } - if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { - screen.interrupt(); - } else { - screen.handle_key(key); + redraw = false; + loop { + redraw |= match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Press => false, + Event::Key(key) + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('c') => + { + screen.interrupt(); + true + } + Event::Key(key) => { + screen.handle_key(key); + true + } + Event::Mouse(m) + if matches!( + m.kind, + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + ) => + { + screen.mouse(m); + true + } + Event::Resize(..) => true, + _ => false, + }; + let more = !screen.done() && screen.pending_work().is_none(); + if !more || !event::poll(std::time::Duration::ZERO)? { + break; } } if let Some(message) = screen.pending_work() { + modes.release(); ratatui::restore(); crate::aux::logging::hold_logs(false); eprintln!("{message}"); screen.do_work(); crate::aux::logging::hold_logs(true); *terminal = ratatui::try_init()?; + redraw = true; } } + modes.release(); // Restore before writing what was held, not after. ratatui::restore(); drop(held); diff --git a/src/sim/handlers.rs b/src/sim/handlers.rs index 0845aa6..8a3992f 100644 --- a/src/sim/handlers.rs +++ b/src/sim/handlers.rs @@ -492,7 +492,7 @@ pub fn run_simulate(cmd_args: &RunSimulateArgs) -> anyhow::Result<()> { .collect(); write_lines(&batch_out, &batch_memb_file)?; - info!("batch membership: {:?}", &batch_memb_file); + info!("batch membership: {:?}", batch_memb_file); let rows: Vec> = (0..sim_args.rows) .map(|i| i.to_string().into_boxed_str()) @@ -515,12 +515,12 @@ pub fn run_simulate(cmd_args: &RunSimulateArgs) -> anyhow::Result<()> { if let Some(ref node_probs) = sim.hierarchy_node_probs { let hierarchy_file = mtx_file.replace(".mtx.gz", ".hierarchy.parquet"); node_probs.to_parquet_with_names(&hierarchy_file, (Some(&rows), Some("feature")), None)?; - info!("wrote hierarchy node probabilities: {:?}", &hierarchy_file); + info!("wrote hierarchy node probabilities: {:?}", hierarchy_file); } info!( "wrote parameter files:\n{:?},\n{:?},\n{:?}", - &ln_batch_file, &dict_file, &prop_file + ln_batch_file, dict_file, prop_file ); if cmd_args.save_mtx { diff --git a/src/sparse_io_vector/batch.rs b/src/sparse_io_vector/batch.rs index 17a3dcf..e47fc9a 100644 --- a/src/sparse_io_vector/batch.rs +++ b/src/sparse_io_vector/batch.rs @@ -117,7 +117,7 @@ impl SparseIoVec { // silently permuted batch ids vs the label order — a latent bug for δ / // `AdjMethod::Batch` consumers. let mut batches_vec: Vec<_> = batches.into_iter().collect(); - batches_vec.sort_by(|a, b| a.0.to_string().cmp(&b.0.to_string())); + batches_vec.sort_by_key(|a| a.0.to_string()); // Schedule largest batches first (LPT) so the heavy tail overlaps with // the small batches. The canonical id rides along; `sort_by_key(idx)` // below restores canonical order. @@ -273,7 +273,7 @@ impl SparseIoVec { let mut col_to_batch = vec![0; ntot]; let mut sorted_batches: Vec<_> = batches.into_iter().collect(); - sorted_batches.sort_by(|a, b| a.0.to_string().cmp(&b.0.to_string())); + sorted_batches.sort_by_key(|a| a.0.to_string()); let mut batch_names = Vec::with_capacity(sorted_batches.len()); let mut batch_to_cols = Vec::with_capacity(sorted_batches.len()); diff --git a/src/sparse_io_vector/matched.rs b/src/sparse_io_vector/matched.rs index af3b6e2..8e35540 100644 --- a/src/sparse_io_vector/matched.rs +++ b/src/sparse_io_vector/matched.rs @@ -63,7 +63,7 @@ impl SparseIoVec { { let (matched, matched_distances) = source_lookup.match_by_query_name_against(&glob, knn, target_lookup)?; - for (glob_matched, dist) in matched.into_iter().zip(matched_distances.into_iter()) { + for (glob_matched, dist) in matched.into_iter().zip(matched_distances) { if glob == glob_matched { continue; // avoid identical cell pairs } @@ -235,9 +235,7 @@ impl SparseIoVec { knn_columns, target_lookup, )?; - for (glob_matched_index, dist) in - matched.into_iter().zip(matched_distances.into_iter()) - { + for (glob_matched_index, dist) in matched.into_iter().zip(matched_distances) { if glob_index == glob_matched_index { continue; } diff --git a/tests/sparse_zarr_backend_tests.rs b/tests/sparse_zarr_backend_tests.rs index 9ffb01b..323b23a 100644 --- a/tests/sparse_zarr_backend_tests.rs +++ b/tests/sparse_zarr_backend_tests.rs @@ -39,7 +39,7 @@ fn dense_to_triplets_by_rows(mat: &Array2, rows: &[usize]) -> Vec<(u64, u64 } fn sort_triplets(mut t: Vec<(u64, u64, f32)>) -> Vec<(u64, u64, f32)> { - t.sort_by(|a, b| (a.1, a.0).cmp(&(b.1, b.0))); + t.sort_by_key(|a| (a.1, a.0)); t } diff --git a/tests/streaming_write_tests.rs b/tests/streaming_write_tests.rs index 7a18dde..7c5e9a9 100644 --- a/tests/streaming_write_tests.rs +++ b/tests/streaming_write_tests.rs @@ -68,7 +68,7 @@ fn stream_write( } fn sorted(mut t: Vec<(u64, u64, f32)>) -> Vec<(u64, u64, f32)> { - t.sort_by(|a, b| (a.1, a.0).cmp(&(b.1, b.0))); + t.sort_by_key(|a| (a.1, a.0)); t } From 54c2b8a3d58d6a34a30e4c7d1307a20a3b81c1e0 Mon Sep 17 00:00:00 2001 From: YPARK Date: Mon, 5 Oct 2026 19:57:59 -0700 Subject: [PATCH 2/2] run_screen: buttons and wheel only, ticks on time, a key hook The mouse is asked for buttons and the wheel, not every move of the pointer, and every mouse event goes to the screen, which says whether it changed. Ticks keep time while events come, so a stream of them cannot keep a view from its background work. run_screen_with hands keys to a caller's function rather than handle_key, for a caller with key rules of its own. Notices go through notify, which drops control characters. --- src/interactive/ui.rs | 128 +++++++++++++++++++++++++----------------- 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/src/interactive/ui.rs b/src/interactive/ui.rs index a592711..b366768 100644 --- a/src/interactive/ui.rs +++ b/src/interactive/ui.rs @@ -3,10 +3,11 @@ //! plot with a y gutter, an x axis, and markers. use ratatui::buffer::Buffer; +use std::time::{Duration, Instant}; + use ratatui::crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, - KeyModifiers, KeyboardEnhancementFlags, MouseButton, MouseEvent, MouseEventKind, - PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, + MouseEvent, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -43,8 +44,8 @@ pub trait Screen { None } fn do_work(&mut self) {} - /// Called about every [`TICK`] while no key comes: true redraws, for a - /// view waiting on work in the background. + /// Called about every [`TICK`], keys or not: true redraws, for a view + /// waiting on work in the background. fn tick(&mut self) -> bool { false } @@ -53,8 +54,12 @@ pub trait Screen { fn takes_mouse(&self) -> bool { false } - /// A left click or a turn of the wheel, at a cell of the screen. - fn mouse(&mut self, _event: MouseEvent) {} + /// A mouse event (a button pressed or let go, or a turn of the wheel) + /// at a cell of the screen; whether the screen changed and is drawn + /// again. + fn mouse(&mut self, _event: MouseEvent) -> bool { + false + } /// Whether the terminal is asked to report keys held with Shift, Alt or /// Ctrl apart (the kitty keyboard protocol; others ignore the request), /// so that Shift+Enter, say, is not taken for Enter. @@ -76,11 +81,30 @@ struct Modes { chords: bool, } +/// Buttons and the wheel (1000) in SGR form (1006); not the pointer's every +/// move, which would wake the loop for nothing. +const MOUSE_ON: &str = "\x1b[?1000h\x1b[?1006h"; +const MOUSE_OFF: &str = "\x1b[?1006l\x1b[?1000l"; + +/// Write `text` to the terminal as it is. +fn send(text: &str) -> bool { + let mut out = std::io::stdout(); + std::io::Write::write_all(&mut out, text.as_bytes()).is_ok() + && std::io::Write::flush(&mut out).is_ok() +} + +/// Ring the terminal's bell and ask it for a desktop notification saying +/// `text` (OSC 9); terminals without either ignore them. +pub fn notify(text: &str) { + let text: String = text.chars().filter(|c| !c.is_control()).collect(); + send(&format!("\x07\x1b]9;{text}\x07")); +} + impl Modes { fn ask(&mut self, screen: &impl Screen) { let mut out = std::io::stdout(); if screen.takes_mouse() && !self.mouse { - self.mouse = ratatui::crossterm::execute!(out, EnableMouseCapture).is_ok(); + self.mouse = send(MOUSE_ON); } if screen.reports_chords() && !self.chords { let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES; @@ -92,7 +116,7 @@ impl Modes { fn release(&mut self) { let mut out = std::io::stdout(); if std::mem::take(&mut self.mouse) { - let _ = ratatui::crossterm::execute!(out, DisableMouseCapture); + send(MOUSE_OFF); } if std::mem::take(&mut self.chords) { let _ = ratatui::crossterm::execute!(out, PopKeyboardEnhancementFlags); @@ -100,7 +124,7 @@ impl Modes { } } -/// How long [`run_screen`] waits for a key before asking [`Screen::tick`]. +/// How often [`run_screen`] asks [`Screen::tick`], keys or not. pub const TICK: std::time::Duration = std::time::Duration::from_millis(200); /// Holds log records back while alive, writing them when dropped. @@ -126,11 +150,24 @@ impl Drop for HeldLogs { /// after it. The mouse and keys held with modifiers are reported only to a /// screen that asks for them, and events already queued (a turn of the wheel /// is several) are taken before the screen is drawn again. -pub fn run_screen(screen: &mut impl Screen) -> anyhow::Result<()> { +pub fn run_screen(screen: &mut S) -> anyhow::Result<()> { + run_screen_with(screen, S::handle_key) +} + +/// [`run_screen`], with each key (but Ctrl-C) handed to `on_key` rather +/// than [`Screen::handle_key`]: for a caller with rules of its own about +/// which keys a screen sees. +pub fn run_screen_with( + screen: &mut S, + mut on_key: impl FnMut(&mut S, KeyEvent), +) -> anyhow::Result<()> { ratatui::run(|terminal| -> anyhow::Result<()> { let held = HeldLogs::new(); let mut modes = Modes::default(); let mut redraw = true; + // Ticks keep time while events come: a stream of them must not + // keep a view from its background work. + let mut next_tick = Instant::now() + TICK; while !screen.done() { if redraw { terminal.draw(|f| screen.render(f))?; @@ -138,53 +175,40 @@ pub fn run_screen(screen: &mut impl Screen) -> anyhow::Result<()> { modes.ask(screen); } if let Some(notice) = screen.take_notice() { - // A bell, and a desktop notification where the terminal shows - // one (OSC 9); terminals without either ignore them. - let mut out = std::io::stdout(); - let _ = std::io::Write::write_all( - &mut out, - format!("\x07\x1b]9;{notice}\x07").as_bytes(), - ); - let _ = std::io::Write::flush(&mut out); - } - if !event::poll(TICK)? { - redraw = screen.tick(); - continue; + notify(¬ice); } redraw = false; - loop { - redraw |= match event::read()? { - Event::Key(key) if key.kind != KeyEventKind::Press => false, - Event::Key(key) - if key.modifiers.contains(KeyModifiers::CONTROL) - && key.code == KeyCode::Char('c') => - { - screen.interrupt(); - true - } - Event::Key(key) => { - screen.handle_key(key); - true - } - Event::Mouse(m) - if matches!( - m.kind, - MouseEventKind::Down(MouseButton::Left) - | MouseEventKind::ScrollUp - | MouseEventKind::ScrollDown - ) => + if event::poll(next_tick.saturating_duration_since(Instant::now()))? { + loop { + redraw |= match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Press => false, + Event::Key(key) + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('c') => + { + screen.interrupt(); + true + } + Event::Key(key) => { + on_key(screen, key); + true + } + Event::Mouse(m) => screen.mouse(m), + Event::Resize(..) => true, + _ => false, + }; + if screen.done() + || screen.pending_work().is_some() + || !event::poll(Duration::ZERO)? { - screen.mouse(m); - true + break; } - Event::Resize(..) => true, - _ => false, - }; - let more = !screen.done() && screen.pending_work().is_none(); - if !more || !event::poll(std::time::Duration::ZERO)? { - break; } } + if Instant::now() >= next_tick { + redraw |= screen.tick(); + next_tick = Instant::now() + TICK; + } if let Some(message) = screen.pending_work() { modes.release(); ratatui::restore();