diff --git a/Cargo.lock b/Cargo.lock index 8ee7187..9965181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -146,6 +155,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -161,9 +176,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.50" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -171,9 +186,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.50" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -199,6 +214,15 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[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 = "colorchoice" version = "1.0.4" @@ -303,6 +327,33 @@ dependencies = [ "litrs", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -319,6 +370,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.0" @@ -349,6 +417,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.1", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -413,9 +490,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" @@ -431,11 +508,10 @@ checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] @@ -445,6 +521,15 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.5" @@ -502,6 +587,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -555,9 +661,9 @@ checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -565,15 +671,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -606,6 +712,16 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -615,6 +731,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -640,6 +773,40 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "rustyline-derive", + "unicode-segmentation", + "unicode-width 0.2.1", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustyline-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -716,6 +883,7 @@ dependencies = [ "clap", "crossterm", "miette", + "rustyline", "serial2-tokio", "sericom-core", "tokio", @@ -731,9 +899,12 @@ dependencies = [ "chrono", "crossterm", "miette", + "parking_lot", "serde", "serial2-tokio", - "thiserror 2.0.17", + "test-log", + "test-sericom", + "thiserror", "tokio", "toml", "tracing", @@ -849,42 +1020,52 @@ dependencies = [ ] [[package]] -name = "textwrap" -version = "0.16.2" +name = "test-log" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" dependencies = [ - "unicode-linebreak", - "unicode-width 0.2.1", + "env_logger", + "test-log-macros", + "tracing-subscriber", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "test-log-macros" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" dependencies = [ - "thiserror-impl 1.0.69", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "thiserror" -version = "2.0.17" +name = "test-sericom" +version = "0.1.0" +dependencies = [ + "libc", + "tracing", +] + +[[package]] +name = "textwrap" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ - "thiserror-impl 2.0.17", + "unicode-linebreak", + "unicode-width 0.2.1", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "thiserror" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1006,9 +1187,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1017,21 +1198,21 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 1.0.69", + "thiserror", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -1040,9 +1221,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -1061,14 +1242,18 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/Cargo.toml b/Cargo.toml index 4b07084..d9535e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["sericom", "sericom-core"] +members = ["sericom", "sericom-core", "test-sericom"] resolver = "3" [workspace.package] @@ -11,14 +11,23 @@ repository = "https://github.com/tkatter/sericom" [workspace.dependencies] chrono = "0.4.42" crossterm = { version = "0.29.0", features = ["osc52", "event-stream"] } -miette = { version = "7.6.0", features = ["fancy"] } -serial2-tokio = { version = "0.1.19", features = ["windows"] } +miette = "7.6.0" tokio = { version = "1.48.0", features = ["full"] } -tracing = "0.1.41" +serial2-tokio = "0.1.19" +tracing = "0.1.43" [patch.crates-io] sericom-core = { path = "sericom-core" } +# Keeps usefull information for perf report +[profile.perf] +inherits = "release" +debug = true # keep DWARF info for perf/flamegraph +lto = "fat" # don't merge crates; keeps crate boundaries visible +codegen-units = 1 # better optimizer consistency +opt-level = 3 # full optimization +strip = "none" # ensure debug info isn't stripped + # The profile that 'dist' will build with [profile.dist] inherits = "release" diff --git a/justfile b/justfile new file mode 100644 index 0000000..83769d5 --- /dev/null +++ b/justfile @@ -0,0 +1,55 @@ +alias b := build +alias br := build-release +alias c := check +alias f := fmt +alias l := lint +alias r := run +alias t := test +alias tt := test-trace + +default-trace := 'sericom-core' +default-tests := '' + +default: check lint + +build: + cargo build + +build-release: + cargo build --release + +check: + cargo check + +check-win: + cargo check --target x86_64-pc-windows-msvc + +clean: + cargo clean + +fmt: + cargo fmt + +lint: + cargo clippy + +list-tests: + cargo test -- --list + +run: + cargo run -p sericom + +run-file: + cargo run -- /dev/ttyUSB0 -f + +testseri: + cargo run -p test-sericom + +run-trace: + cargo run -- /dev/ttyUSB0 -d + +test tests=default-tests: + cargo test {{tests}} --no-fail-fast --lib + +test-trace tests=default-tests: + RUST_LOG='sericom_core::screen::buffer=debug,sericom_core=trace' cargo test {{tests}} --no-fail-fast --lib -- --nocapture diff --git a/sericom-core/Cargo.toml b/sericom-core/Cargo.toml index 8aa70d1..719358e 100644 --- a/sericom-core/Cargo.toml +++ b/sericom-core/Cargo.toml @@ -9,12 +9,22 @@ exclude.workspace = true repository.workspace = true [dependencies] +parking_lot = "0.12.5" serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.17" toml = "0.9.8" chrono.workspace = true crossterm.workspace = true miette.workspace = true -serial2-tokio.workspace = true +serial2-tokio = { workspace = true, features = ["windows"] } tokio.workspace = true -tracing.workspace = true +tracing = { workspace = true, features = ["attributes"] } + +[dev-dependencies] +test-log = {version = "0.2.18", default-features = false, features = ["trace", "color"]} +test-sericom = { version = "0.1.0", path = "../test-sericom" } + +[lints.clippy] +pedantic = "warn" +perf = "warn" +nursery = "warn" diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index 6b5d1df..69e7e33 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -1,116 +1,15 @@ //! This module holds the functions that are called from `sericom` when receiving //! CLI commands/arguments. -use crate::{ - compat_port_path, - configs::get_config, - create_recursive, - debug::run_debug_output, - map_miette, - screen_buffer::UICommand, - serial_actor::{ - SerialActor, SerialEvent, SerialMessage, - tasks::{run_file_output, run_stdin_input, run_stdout_output}, - }, -}; -use crossterm::{ - cursor, event, execute, - style::Stylize, - terminal::{self, ClearType}, -}; -use miette::{Context, IntoDiagnostic}; use serial2_tokio::SerialPort; -use std::{ - io::{self, Write}, - path::PathBuf, -}; -use tracing::{Level, trace}; +use std::path::PathBuf; -/// Spawns all of the tasks responsible for maintaining an interactive terminal session. -pub async fn interactive_session( - connection: SerialPort, - file_path: Option>, - debug: bool, - port_name: &str, -) -> miette::Result<()> { - let span = tracing::span!(Level::TRACE, "Interactive Session"); - let _enter = span.enter(); - // Setup terminal - let mut stdout = io::stdout(); - terminal::enable_raw_mode() - .into_diagnostic() - .wrap_err("Failed to enable raw mode.".red())?; - execute!( - stdout, - terminal::EnterAlternateScreen, - terminal::SetTitle(port_name), - terminal::Clear(ClearType::All), - event::EnableBracketedPaste, - event::EnableMouseCapture, - cursor::MoveTo(0, 0) - ) - .into_diagnostic() - .wrap_err("Failed to setup the terminal.".red())?; - let config = get_config(); - - trace!("Creating channels"); - // Create channels - let (command_tx, command_rx) = tokio::sync::mpsc::channel::(100); - let (ui_tx, ui_rx) = tokio::sync::mpsc::channel::(100); - let (broadcast_event_tx, _) = tokio::sync::broadcast::channel::(128); - let stdout_rx = broadcast_event_tx.subscribe(); - - // Create tasks - let mut tasks = tokio::task::JoinSet::new(); - - if let Some(maybe_path) = file_path { - let default_out_dir = PathBuf::from(&config.defaults.out_dir); - let file_path = match maybe_path { - Some(path) => { - // If given an absolute path - override the `default_out_dir` - if path.is_absolute() { - let parent = path.parent().unwrap_or(&default_out_dir); - create_recursive!(parent); - path - } else { - let joined_path = default_out_dir.join(&path); - let parent_path = joined_path.parent().expect("Does not have root"); - create_recursive!(parent_path); - joined_path - } - } - None => { - let default_out_dir = PathBuf::from(&config.defaults.out_dir); - compat_port_path!(default_out_dir, port_name) - } - }; - let file_rx = broadcast_event_tx.subscribe(); - tasks.spawn(async move { - run_file_output(file_rx, file_path.clone()).await; - run_file_exit_script(config, file_path); - }); - }; - - if debug { - let debug_rx = broadcast_event_tx.subscribe(); - tasks.spawn(run_debug_output(debug_rx)); - } - - let actor = SerialActor::new(connection, command_rx, broadcast_event_tx); - tasks.spawn(actor.run()); - - tasks.spawn(run_stdout_output(stdout_rx, ui_rx)); - tasks.spawn(run_stdin_input(command_tx, ui_tx)); - - tasks.join_all().await; - ensure_terminal_cleanup(stdout); - Ok(()) -} +use crate::{SeriError, err_into, map_miette}; /// Opens a serial `port` for communication with the specified `baud`. /// /// Returns `Ok(SerialPort)` or errors if unable to set the baud rate or open the `port`. -pub fn open_connection(baud: u32, port: &str) -> miette::Result { +pub fn open_connection(baud: u32, port: &PathBuf) -> crate::Result { let settings = |mut s: serial2_tokio::Settings| -> std::io::Result { s.set_raw(); s.set_baud_rate(baud)?; @@ -120,156 +19,68 @@ pub fn open_connection(baud: u32, port: &str) -> miette::Result { s.set_flow_control(serial2_tokio::FlowControl::None); Ok(s) }; - let con = map_miette!( - SerialPort::open(port, settings), - format!("Failed to open port '{}'", port), - format!( - "{} {} [OPTIONS] [PORT] [COMMAND]", - "USAGE:".bold().underlined(), - "sericom".bold() - ), - help = format!( - "To see available ports, try `{}`.", - "sericom list-ports".bold().cyan() - ) - )?; + + let con = SerialPort::open(port, settings).map_err(|e| err_into!(e, SeriError::Connection))?; + Ok(con) } /// Gets the settings for the `port` with the specified `baud`. -pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { +#[allow(clippy::many_single_char_names)] +pub fn get_settings(baud: u32, port: &PathBuf) -> miette::Result<()> { // https://www.contec.com/support/basic-knowledge/daq-control/serial-communicatin/ - let mut stdout = io::stdout(); let con = open_connection(baud, port)?; let settings = map_miette!( con.get_configuration(), - format!("Failed to get settings for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get settings for port '{}'", port.display()) )?; let b = map_miette!( settings.get_baud_rate(), - format!("Failed to get the baud rate for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get the baud rate for port '{}'", port.display()) )?; let c = map_miette!( settings.get_char_size(), - format!("Failed to get the char size for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get the char size for port '{}'", port.display()) )?; let s = map_miette!( settings.get_stop_bits(), - format!("Failed to get stop bits for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get stop bits for port '{}'", port.display()) )?; let p = map_miette!( settings.get_parity(), - format!("Failed to get parity for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get parity for port '{}'", port.display()) )?; let f = map_miette!( settings.get_flow_control(), - format!("Failed to get flow control for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get flow control for port '{}'", port.display()) )?; let cts = map_miette!( con.read_cts(), - format!("Failed to read CTS for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to read CTS for port '{}'", port.display()) )?; let dsr = map_miette!( con.read_dsr(), - format!("Failed to read DSR for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to read DSR for port '{}'", port.display()) )?; let ri = map_miette!( con.read_ri(), - format!("Failed to read RI for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to read RI for port '{}'", port.display()) )?; let cd = map_miette!( con.read_cd(), - format!("Failed to read CD for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to read CD for port '{}'", port.display()) )?; - write!(stdout, "Baud rate: {b}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Char size: {c}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Stop bits: {s}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Parity mechanism: {p}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Flow control: {f}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Clear To Send line: {cts}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Data Set Ready line: {dsr}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Ring Indicator line: {ri}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Carrier Detect line: {cd}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; + println!("Baud rate: {b}"); + println!("Char size: {c}"); + println!("Stop bits: {s}"); + println!("Parity mechanism: {p}"); + println!("Flow control: {f}"); + println!("Clear To Send line: {cts}"); + println!("Data Set Ready line: {dsr}"); + println!("Ring Indicator line: {ri}"); + println!("Carrier Detect line: {cd}"); Ok(()) } @@ -277,29 +88,21 @@ pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { /// Prints a list of available serial ports to stdout. /// /// Ultimately a wrapper around [`SerialPort::available_ports()`] and may error -/// if it is called on an unsupported platform as per [`SerialPort::available_ports()]s docs -pub fn list_serial_ports() -> miette::Result<()> { - let mut stdout = io::stdout(); - let ports = map_miette!( - SerialPort::available_ports(), - "Could not list available ports." - )?; +/// if it is called on an unsupported platform as per [`SerialPort::available_ports()`]s docs +pub fn list_serial_ports() -> crate::Result<()> { + let ports = SerialPort::available_ports()?; + for path in ports { - if let Some(path) = path.to_str() { - let line = [path, "\r\n"].concat(); - stdout - .write(line.as_bytes()) - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())? - } else { - continue; - }; + println!("{}", path.display()); } Ok(()) } -/// Used as a [`value_parser`](https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser) for [`sericom`](https://crates.io/crates/sericom)s [`clap`](https://docs.rs/clap) CLI -/// struct to validate and parse args into a baud rate. +/// Used as a [`value_parser`] for [`sericom`]s [`clap`] CLI +/// +/// [`value_parser`]: https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser +/// [`sericom`]: https://crates.io/crates/sericom +/// [`clap`]: https://crates.io/crates/clap pub fn valid_baud_rate(s: &str) -> Result { let baud: u32 = s .parse() @@ -307,16 +110,16 @@ pub fn valid_baud_rate(s: &str) -> Result { if serial2_tokio::COMMON_BAUD_RATES.contains(&baud) { Ok(baud) } else { - Err(format!( - "'{}' is not a valid baud rate; valid baud rates include {:?}", - baud, - serial2_tokio::COMMON_BAUD_RATES - )) + Err(format!("'{baud}' is not a valid baud rate")) } } -/// Used as a [`value_parser`](https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser) for [`sericom`](https://crates.io/crates/sericom)s [`clap`](https://docs.rs/clap) CLI -/// struct to validate and parse args into a [`SeriColor`][`crate::configs::SeriColor`]. +/// Used as a [`value_parser`] for [`sericom`]s [`clap`] CLI to parse args into a [`SeriColor`]. +/// +/// [`value_parser`]: https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser +/// [`sericom`]: https://crates.io/crates/sericom +/// [`clap`]: https://crates.io/crates/clap +/// [`SeriColor`]: crate::configs::SeriColor pub fn color_parser(input: &str) -> Result { use crate::configs::{NORMALIZER, SeriColor}; match SeriColor::parse_from_str(input, NORMALIZER) { @@ -325,6 +128,7 @@ pub fn color_parser(input: &str) -> Result { } } +/* fn ensure_terminal_cleanup(mut stdout: io::Stdout) { use crossterm::{ cursor::Show, @@ -342,10 +146,8 @@ fn ensure_terminal_cleanup(mut stdout: io::Stdout) { let _ = stdout.flush(); } -fn run_file_exit_script(config: &'static crate::configs::Config, file_path: PathBuf) { - let span = tracing::span!(Level::DEBUG, "Exit script"); - let _enter = span.enter(); - +fn run_file_exit_script(file_path: PathBuf) { + let config = crate::configs::get_config().unwrap(); //TODO: HANDLE let Some(script_path) = config.defaults.exit_script.as_ref() else { return; }; @@ -354,12 +156,12 @@ fn run_file_exit_script(config: &'static crate::configs::Config, file_path: Path .expect("All error conditions have been checked"); let cmd = create_platform_cmd(script_path, full_file_path); if let Ok(output) = cmd { - let msg = format!( + tracing::debug!( + target: "exit_script", "stdout: {}, stderr: {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - tracing::debug!(msg); } } @@ -397,3 +199,4 @@ fn create_platform_cmd( } } } +*/ diff --git a/sericom-core/src/configs/appearance.rs b/sericom-core/src/configs/appearance.rs index 2d66238..5638f09 100644 --- a/sericom-core/src/configs/appearance.rs +++ b/sericom-core/src/configs/appearance.rs @@ -11,7 +11,7 @@ use std::borrow::Cow; /// fg = "green" /// bg = "none" /// ``` -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Debug, Deserialize, PartialEq, Eq)] pub struct Appearance { #[serde(default = "default_fg")] pub fg: SeriColor, @@ -19,10 +19,10 @@ pub struct Appearance { pub bg: SeriColor, } -fn default_fg() -> SeriColor { +const fn default_fg() -> SeriColor { SeriColor::Green } -fn default_bg() -> SeriColor { +const fn default_bg() -> SeriColor { SeriColor::None } @@ -128,23 +128,23 @@ impl SeriColor { let normalized_cow = normalizer(input_slice); let normalized_str = normalized_cow.as_ref(); match normalized_str { - "black" => Ok(SeriColor::Black), - "blue" => Ok(SeriColor::Blue), - "cyan" => Ok(SeriColor::Cyan), - "darkblue" => Ok(SeriColor::DarkBlue), - "darkcyan" => Ok(SeriColor::DarkCyan), - "darkgreen" => Ok(SeriColor::DarkGreen), - "darkgrey" | "darkgray" => Ok(SeriColor::DarkGrey), - "darkmagenta" => Ok(SeriColor::DarkMagenta), - "darkred" => Ok(SeriColor::DarkRed), - "darkyellow" => Ok(SeriColor::DarkYellow), - "default" => Ok(SeriColor::None), - "green" => Ok(SeriColor::Green), - "grey" | "gray" => Ok(SeriColor::Grey), - "magenta" => Ok(SeriColor::Magenta), - "red" => Ok(SeriColor::Red), - "white" => Ok(SeriColor::White), - "yellow" => Ok(SeriColor::Yellow), + "black" => Ok(Self::Black), + "blue" => Ok(Self::Blue), + "cyan" => Ok(Self::Cyan), + "darkblue" => Ok(Self::DarkBlue), + "darkcyan" => Ok(Self::DarkCyan), + "darkgreen" => Ok(Self::DarkGreen), + "darkgrey" | "darkgray" => Ok(Self::DarkGrey), + "darkmagenta" => Ok(Self::DarkMagenta), + "darkred" => Ok(Self::DarkRed), + "darkyellow" => Ok(Self::DarkYellow), + "default" => Ok(Self::None), + "green" => Ok(Self::Green), + "grey" | "gray" => Ok(Self::Grey), + "magenta" => Ok(Self::Magenta), + "red" => Ok(Self::Red), + "white" => Ok(Self::White), + "yellow" => Ok(Self::Yellow), _ => Err(VALID_SERICOLORS), } } @@ -156,7 +156,7 @@ impl<'de> Deserialize<'de> for SeriColor { D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - match SeriColor::parse_from_str(&s, NORMALIZER) { + match Self::parse_from_str(&s, NORMALIZER) { Ok(s) => Ok(s), Err(valid_colors) => Err(serde::de::Error::unknown_variant(&s, valid_colors)), } @@ -164,25 +164,25 @@ impl<'de> Deserialize<'de> for SeriColor { } impl From<&SeriColor> for crossterm::style::Color { - fn from(value: &SeriColor) -> crossterm::style::Color { + fn from(value: &SeriColor) -> Self { match value { - SeriColor::Black => crossterm::style::Color::Black, - SeriColor::Blue => crossterm::style::Color::Blue, - SeriColor::Cyan => crossterm::style::Color::Cyan, - SeriColor::DarkBlue => crossterm::style::Color::DarkBlue, - SeriColor::DarkCyan => crossterm::style::Color::DarkCyan, - SeriColor::DarkGreen => crossterm::style::Color::DarkGreen, - SeriColor::DarkGrey => crossterm::style::Color::DarkGrey, - SeriColor::DarkMagenta => crossterm::style::Color::DarkMagenta, - SeriColor::DarkRed => crossterm::style::Color::DarkRed, - SeriColor::DarkYellow => crossterm::style::Color::DarkYellow, - SeriColor::Green => crossterm::style::Color::Green, - SeriColor::Grey => crossterm::style::Color::Grey, - SeriColor::Magenta => crossterm::style::Color::Magenta, - SeriColor::None => crossterm::style::Color::Reset, - SeriColor::Red => crossterm::style::Color::Red, - SeriColor::White => crossterm::style::Color::White, - SeriColor::Yellow => crossterm::style::Color::Yellow, + SeriColor::Black => Self::Black, + SeriColor::Blue => Self::Blue, + SeriColor::Cyan => Self::Cyan, + SeriColor::DarkBlue => Self::DarkBlue, + SeriColor::DarkCyan => Self::DarkCyan, + SeriColor::DarkGreen => Self::DarkGreen, + SeriColor::DarkGrey => Self::DarkGrey, + SeriColor::DarkMagenta => Self::DarkMagenta, + SeriColor::DarkRed => Self::DarkRed, + SeriColor::DarkYellow => Self::DarkYellow, + SeriColor::Green => Self::Green, + SeriColor::Grey => Self::Grey, + SeriColor::Magenta => Self::Magenta, + SeriColor::None => Self::Reset, + SeriColor::Red => Self::Red, + SeriColor::White => Self::White, + SeriColor::Yellow => Self::Yellow, } } } diff --git a/sericom-core/src/configs/defaults.rs b/sericom-core/src/configs/defaults.rs index 311f09e..d7abab3 100644 --- a/sericom-core/src/configs/defaults.rs +++ b/sericom-core/src/configs/defaults.rs @@ -2,6 +2,7 @@ use crate::path_utils::{ExpandPaths, is_executable}; use serde::{Deserialize, Deserializer}; use std::path::PathBuf; +#[allow(clippy::doc_markdown)] /// Represents the `[defaults]` table of the `config.toml` file. /// /// The `[defaults]` table holds configuration values for how sericom @@ -9,7 +10,7 @@ use std::path::PathBuf; /// where files will be created when running `sericom -f path/to/file [PORT]`. /// /// The default values (if no config exists) is the current directory. Uses -/// [`current_dir`] which corresponds to getcwd on Unix and GetCurrentDirectoryW +/// [`current_dir`] which corresponds to getcwd on Unix and GetCurrentDirectory /// on Windows. If this fails it simply uses "./". /// /// ```toml @@ -21,7 +22,7 @@ use std::path::PathBuf; /// ``` /// /// [`current_dir`]: std::env::current_dir() -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Debug, Deserialize, Eq, PartialEq)] pub struct Defaults { #[serde(rename = "out-dir")] #[serde(default = "default_out_dir")] @@ -51,7 +52,12 @@ impl Default for Defaults { fn default_out_dir() -> PathBuf { use std::env::current_dir; - current_dir().unwrap_or(PathBuf::from("./")) + current_dir().unwrap_or_else(|_| { + #[cfg(not(windows))] + return PathBuf::from("./"); + #[cfg(windows)] + return PathBuf::from(".\\"); + }) } fn validate_dir<'de, D>(deserializer: D) -> Result @@ -62,7 +68,7 @@ where let p = PathBuf::deserialize(deserializer)? .get_expanded_path() - .ok_or(Error::custom("Error expanding path."))?; + .ok_or_else(|| Error::custom("Error expanding path."))?; if !p.exists() || !p.is_dir() { return Err(serde::de::Error::custom( "Error setting out-dir, Either does not exist or is not a directory", @@ -79,7 +85,7 @@ where let p = PathBuf::deserialize(deserializer)? .get_expanded_path() - .ok_or(Error::custom("Error expanding path."))?; + .ok_or_else(|| Error::custom("Error expanding path."))?; if !p.exists() || !p.is_file() { return Err(serde::de::Error::custom( "Error retrieving file, Either does not exist or is not a file", diff --git a/sericom-core/src/configs/mod.rs b/sericom-core/src/configs/mod.rs index 3300285..759d43c 100644 --- a/sericom-core/src/configs/mod.rs +++ b/sericom-core/src/configs/mod.rs @@ -13,7 +13,12 @@ use crate::{ create_recursive, }; use serde::Deserialize; -use std::{io::Read, ops::Range, path::PathBuf, sync::OnceLock}; +use std::{ + io::Read, + ops::Range, + path::PathBuf, + sync::{LockResult, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}, +}; /// Global value of the user's config. /// @@ -21,13 +26,15 @@ use std::{io::Read, ops::Range, path::PathBuf, sync::OnceLock}; /// underlying [`Config`] must be made before calling [`initialize_config()`]. /// /// To get a reference to the global config during runtime, call [`get_config()`]. -pub static CONFIG: OnceLock = OnceLock::new(); +pub static CONFIG: OnceLock> = OnceLock::new(); /// Represents the entire `config.toml` configuration file. /// /// See [`Appearance`] and [`Defaults`] -#[derive(Default, Debug, Deserialize, PartialEq)] +#[derive(Default, Debug, Deserialize, PartialEq, Eq)] pub struct Config { + // Global fields for cli behaviors?? + // color: ["always", "never", "auto"] #[serde(default)] pub appearance: Appearance, #[serde(default)] @@ -42,12 +49,24 @@ impl Config { if let Some(dir) = overrides.out_dir { self.defaults.out_dir = dir; } - if let Some(script) = overrides.exit_script { + if let Some(script) = overrides.script { self.defaults.exit_script = Some(script); } } } +#[cfg(test)] +static INIT: std::sync::Once = std::sync::Once::new(); + +#[cfg(test)] +pub fn init_for_tests() { + INIT.call_once(|| { + CONFIG + .set(RwLock::new(Config::default())) + .expect("init config"); + }); +} + /// This function constructs a global `static CONFIG` for the rest of the program's /// duration to provide a reference to the config for the remainder of the program. /// @@ -60,7 +79,7 @@ impl Config { /// /// Returns a [`ConfigError::AlreadyInitialized`] error if called after it has /// already been called ([`CONFIG`] has already been set). -pub fn initialize_config(overrides: ConfigOverride) -> miette::Result<(), ConfigError> { +pub fn initialize_config(overrides: Option) -> miette::Result<(), ConfigError> { let mut config: Config = if let Ok(config_file) = get_config_file() { let mut file = std::fs::File::open(config_file).expect("File should exist"); let mut contents = String::new(); @@ -76,14 +95,17 @@ pub fn initialize_config(overrides: ConfigOverride) -> miette::Result<(), Config Config::default() }; - config.apply_overrides(overrides); + if let Some(overrides) = overrides { + config.apply_overrides(overrides); + } CONFIG - .set(config) + .set(RwLock::new(config)) .map_err(|_| ConfigError::AlreadyInitialized)?; Ok(()) } +// TODO: UPDATE DOCS /// When called, [`get_config()`] returns a reference to the global [`CONFIG`] /// that was initialized at the start of the program. /// @@ -91,8 +113,21 @@ pub fn initialize_config(overrides: ConfigOverride) -> miette::Result<(), Config /// /// ## Panics /// Will panic if [`CONFIG`] as not been initialized before calling with [`initialize_config()`]. -pub fn get_config() -> &'static Config { - CONFIG.get().expect("Config not initialized") +pub fn get_config<'a>() -> miette::Result> { + // thinking is not try_read because when this method is called, it is + // called because the values _are needed_ for initializing other things + // so returning an Err from try_read is not helpful - would rather have it + // block until it gets a read lock than get an err if it wasn't ready + CONFIG + .get() + .expect("Config not initialized") + .read() + .map_err(|e| miette::miette!("{e}").wrap_err("Failed to read config")) +} + +// TODO: UPDATE DOCS +pub fn get_mut_config<'a>() -> LockResult> { + CONFIG.get().expect("Config not initialized").write() } #[derive(Debug)] @@ -103,7 +138,7 @@ pub struct ConfigOverride { /// Overrides [`Defaults::out_dir`] pub out_dir: Option, /// Overrides [`Defaults::exit_script`] - pub exit_script: Option, + pub script: Option, } fn get_conf_dir() -> std::path::PathBuf { @@ -144,7 +179,6 @@ fn parse_test_config() -> miette::Result<()> { [defaults] out-dir = "$HOME/.config" - exit-script = "~/.local/bin/format-cisco" "#, ) .into_diagnostic()?; @@ -156,9 +190,7 @@ fn parse_test_config() -> miette::Result<()> { }, defaults: Defaults { out_dir: PathBuf::from("/home/thomas/.config"), - exit_script: Some(PathBuf::from("/home/thomas/.local/bin/format-cisco")), - debug_dir: PathBuf::from("/home/thomas/Code/Work/sericom/sericom-core"), - // file_exit_script: None, + ..Default::default() }, }; @@ -169,16 +201,16 @@ fn parse_test_config() -> miette::Result<()> { #[test] fn check_conf_dir_is_dir() { let dir = get_conf_dir(); - assert!(std::fs::metadata(dir).unwrap().is_dir()) + assert!(std::fs::metadata(dir).unwrap().is_dir()); } #[test] fn valid_conf_dir() { let dir = get_conf_dir(); if cfg!(target_family = "windows") { - assert_eq!(dir.to_str().unwrap(), "C:\\Users\\Thomas\\.config\\sericom") + assert_eq!(dir.to_str().unwrap(), "C:\\Users\\Thomas\\.config\\sericom"); } else { - assert_eq!(dir.to_str().unwrap(), "/home/thomas/.config/sericom") + assert_eq!(dir.to_str().unwrap(), "/home/thomas/.config/sericom"); } } @@ -203,7 +235,7 @@ fn get_expanded_path() { p2, PathBuf::from("/home/thomas/.config/sericom/config.toml") ); - assert_eq!(p3, PathBuf::from("/home/thomas/.config/some/path")) + assert_eq!(p3, PathBuf::from("/home/thomas/.config/some/path")); } // #[test] diff --git a/sericom-core/src/debug.rs b/sericom-core/src/debug.rs deleted file mode 100644 index 6947ce3..0000000 --- a/sericom-core/src/debug.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! As of now, there is only one function, [`run_debug_output`], which is meant -//! to debug the data being received over the serial connection. In future -//! updates, this module is intended to be used for running tracing events with -//! the [`tracing`](https://docs.rs/tracing/latest/tracing/) crate. - -use crate::serial_actor::SerialEvent; - -/// This function is used for debugging the data that is sent from a device. -/// It will create a file "debug.txt" and print the data received from the device -/// as the actual bytes received along with the corresponding ascii characters. -/// -/// Data is sent from the [`SerialActor`][crate::serial_actor::SerialActor] to this function in batches, -/// therefore a line written to "debug.txt" may look like this: -/// -/// "\[04:41:27.550\] RX 9 bytes: \[0D, 0A, 53, 77, 69, 74, 63, 68\]... UTF8: ^M Switch#" -/// -/// Each line will only print a maximum of 8 bytes, after 8 it will simply write "...". -pub async fn run_debug_output(mut rx: tokio::sync::broadcast::Receiver) { - use std::io::{BufWriter, Write}; - use std::path::Path; - - let (write_tx, write_rx) = std::sync::mpsc::channel::>(); - let write_handle = tokio::task::spawn_blocking(move || { - let path = Path::new("./debug.txt"); - let file = match std::fs::File::create(path) { - Ok(f) => f, - Err(e) => { - eprintln!("Failed to create file: {e}"); - return; - } - }; - let mut writer = BufWriter::with_capacity(48 * 1024, file); - let mut last_flush = std::time::Instant::now(); - - writeln!(writer, "Session started at: {}", chrono::Utc::now()).ok(); - while let Ok(data) = write_rx.recv() { - // let control_bytes_for_hex: Vec = data[..std::cmp::min(20, data.len())] - // .iter() - // .filter(|b| b.is_ascii_control()) - // .cloned() - // .collect(); - // Only prints the bytes of ASCII escape characters - // writeln!( - // writer, - // "RX {} bytes: {:02X?}{} UTF8: {}", - // data.len(), - // control_bytes_for_hex, - // if data.len() > 20 { "..." } else { "" }, - // String::from_utf8_lossy(&data) - // ) - // .ok(); - // Prints bytes of all characters - writeln!( - writer, - "[{}] RX {} bytes: {:02X?}{} UTF8: {}", - chrono::Utc::now().format("%H:%M:%S%.3f"), - data.len(), - &data[..std::cmp::min(20, data.len())], - if data.len() > 10 { "..." } else { "" }, - String::from_utf8_lossy(&data) - ) - .ok(); - - let now = std::time::Instant::now(); - if now.duration_since(last_flush) > std::time::Duration::from_millis(100) - || writer.buffer().len() > 32 * 1024 - { - let _ = writer.flush(); - last_flush = now; - } - } - let _ = writer.flush(); - }); - - let data_streamer = tokio::spawn(async move { - let mut write_buf = Vec::with_capacity(4096); - let mut batch_timer = tokio::time::interval(tokio::time::Duration::from_millis(200)); - - loop { - tokio::select! { - event = rx.recv() => { - match event { - Ok(SerialEvent::Data(data)) => { - write_buf.extend_from_slice(&data); - if write_buf.len() >= 4096 && write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - eprintln!("File writer lagged, skipped {skipped} messages"); - continue; // Don't break on lag - } - _ => break, - } - } - _ = batch_timer.tick() => { - if !write_buf.is_empty() && write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - } - } - } - if !write_buf.is_empty() { - let _ = write_tx.send(std::mem::take(&mut write_buf)); - } - drop(write_tx); - }); - - let _ = data_streamer.await; - let _ = write_handle.await; -} diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index b0f90fb..39d24d9 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -8,10 +8,16 @@ //! create an [issue](https://github.com/tkatter/sericom) so I can become aware and work //! towards making `sericom-core` a generalized/compatible library that is better suited //! for use among other crates. +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::missing_panics_doc)] +// #![feature(vec_into_raw_parts)] pub mod cli; pub mod configs; -pub mod debug; pub mod path_utils; -pub mod screen_buffer; +pub mod screen; pub mod serial_actor; +pub mod session; +pub mod ui; +pub use session::SeriError; +pub type Result = std::result::Result; diff --git a/sericom-core/src/path_utils/macros.rs b/sericom-core/src/path_utils/macros.rs index 914b046..b1d5bca 100644 --- a/sericom-core/src/path_utils/macros.rs +++ b/sericom-core/src/path_utils/macros.rs @@ -1,5 +1,7 @@ //! This module holds helper macros for dealing with paths +/// Recursively create a directory. +/// /// Takes a [`&Path`][std::path::Path] and first checks whether it exists or if it is a /// directory. If it doesn't exist or is not a directory, it will create /// the directory recursively; creating the necessary parent directories. @@ -32,12 +34,10 @@ macro_rules! create_recursive { /// Used to add a `.map_err()` to function calls that return a `Result` /// to provide better context for the error and print it nicely to stdout. /// -/// Takes 2 arguements and optionally a third and fourth: +/// Takes 2 arguements and optionally a third for an additional help message. /// - The first argument is the expression or function call that would return a `Result` /// - The second argument is context that better describes the returned error -/// - The optional third argument is the 'USAGE: sericom ...' that would typically be printed by `clap` -/// for the respective command -/// - The optional fourth argument is an additional "help:" message +/// - The optional third argument is an additional "help:" message /// /// ## Example /// ``` @@ -50,14 +50,7 @@ macro_rules! create_recursive { /// let x = map_miette!( /// SerialPort::open(port, baud), /// format!("Failed to open port '{}'", port), -/// format!("{} {} [OPTIONS] [PORT] [COMMAND]", -/// "USAGE:".bold().underlined(), -/// "sericom".bold() -/// ), -/// help = format!( -/// "To see available ports, try `{}`.", -/// "sericom list-ports".bold().cyan() -/// ) +/// help = "To see available ports, try `list ports`." /// )?; /// Ok(()) /// } @@ -66,45 +59,22 @@ macro_rules! create_recursive { /// ``` #[macro_export] macro_rules! map_miette { - // Clap-style USAGE: && additional "help" message - ($expr:expr, $wrap_msg:expr, $usage:expr, help = $add_help:expr) => { - $expr.map_err(|e| { - use crossterm::style::Stylize; - miette::miette!( - help = format!("{}\nFor more information, try `sericom --help`.", $add_help), - "{e}" - ) - .wrap_err(format!("{}\n\n{}\n", $wrap_msg, $usage).red()) - }) - }; - - // Clap-style USAGE: && default "help" message - ($expr:expr, $wrap_msg:expr, $usage:expr) => { + // Default "help" message + ($expr:expr, $wrap_msg:expr) => { $expr.map_err(|e| { - use crossterm::style::Stylize; - miette::miette!(help = "For more information, try `sericom --help`.", "{e}") - .wrap_err(format!("{}\n\n{}\n", $wrap_msg, $usage).red()) + miette::miette!(help = "For more information, try `help [COMMAND]`.", "{e}") + .wrap_err($wrap_msg) }) }; // Additional "help" message ($expr:expr, $wrap_msg:expr, help = $add_help:expr) => { $expr.map_err(|e| { - use crossterm::style::Stylize; miette::miette!( - help = format!("{}\nFor more information, try `sericom --help`.", $add_help), + help = format!("{}\nFor more information, try `help [COMMAND]`.", $add_help), "{e}" ) - .wrap_err(format!("{}", $wrap_msg).red()) - }) - }; - - // Default "help" message - ($expr:expr, $wrap_msg:expr) => { - $expr.map_err(|e| { - use crossterm::style::Stylize; - miette::miette!(help = "For more information, try `sericom --help`.", "{e}") - .wrap_err(format!("{}", $wrap_msg).red()) + .wrap_err($wrap_msg) }) }; } @@ -188,6 +158,15 @@ macro_rules! compat_port_path { )) }}; + (trace, $dir:expr) => {{ + use chrono; + + $dir.join(format!( + "sericom-log-{}.txt", + chrono::Utc::now().format("%m%d%H%M") + )) + }}; + ($out_dir:expr, $port:expr) => {{ use chrono; @@ -203,11 +182,19 @@ macro_rules! compat_port_path { use chrono; let path_port = $crate::path_utils::get_compat_port_path($port)?; - PathBuf::from(format!( - "./{}-{}.txt", - path_port.display(), - chrono::Utc::now().format("%m%d%H%M"), - )) + if path_port.is_absolute() { + PathBuf::from(format!( + "{}-{}.txt", + path_port.display(), + chrono::Utc::now().format("%m%d%H%M"), + )) + } else { + PathBuf::from(format!( + "./{}-{}.txt", + path_port.display(), + chrono::Utc::now().format("%m%d%H%M"), + )) + } }}; } @@ -345,3 +332,16 @@ impl ExpandPaths for std::path::PathBuf { Some(self) } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + #[test] + fn assert_expanded_home_path() { + use crate::path_utils::ExpandPaths; + + let path = PathBuf::from("~"); + assert_eq!(path.get_expanded_path(), std::env::home_dir()); + } +} diff --git a/sericom-core/src/path_utils/validators.rs b/sericom-core/src/path_utils/validators.rs index 35336a2..d9c6352 100644 --- a/sericom-core/src/path_utils/validators.rs +++ b/sericom-core/src/path_utils/validators.rs @@ -1,6 +1,7 @@ -use crate::path_utils::ExpandPaths; use std::path::PathBuf; +use crate::path_utils::ExpandPaths; + /// Validates a directory /// /// Expands path and checks if it exists and is a directory. @@ -63,7 +64,8 @@ pub fn is_script(input: &str) -> Result, String> { Ok(Some(p)) } -pub(crate) fn is_executable(path: &std::path::Path) -> bool { +#[must_use] +pub fn is_executable(path: &std::path::Path) -> bool { #[cfg(unix)] { use std::{fs::metadata, os::unix::fs::MetadataExt}; diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs new file mode 100644 index 0000000..f63637f --- /dev/null +++ b/sericom-core/src/screen/buffer.rs @@ -0,0 +1,349 @@ +use crossterm::style::Attributes; +use std::collections::VecDeque; + +use crate::screen::process::{EDKind, ELKind}; +use crate::screen::{BuffPos, Cell, UICommand}; + +use super::position::{Position, TermPos, TranslatePos}; +use super::{Line, Rect}; + +/// The maximum number of lines stored in memory in [`ScreenBuffer`]. +pub const MAX_SCROLLBACK: u32 = 10000; + +/// The `ScreenBuffer` holds rendering state for the entire terminal's window/frame. +/// +/// It mainly serves to allow for user-interactions that require a history and location +/// of the data displayed within the terminal i.e. copy/paste, scrolling, & highlighting. +#[derive(Debug)] +pub struct ScreenBuffer { + /// Scrollback buffer (all lines received from the serial connection). + /// Limited by [`MAX_SCROLLBACK`]. + pub(crate) lines: VecDeque, + /// Denotes which line (as idx in [`Self::lines`]) is at the top of the screen. + /// + /// [`Self::Lines`]: ScreenBuffer::lines + pub(crate) view_start: u32, + /// The terminal's dimensions + pub(crate) rect: Rect, + /// Position of the cursor within the `ScreenBuffer`. + pub(crate) cursor: Position, + /// Start of text selection. Used for highlighting and copying to clipboard. + selection_start: Option<(u16, u32)>, + /// End of text selection. Used for highlighting and copying to clipboard. + selection_end: Option<(u16, u32)>, + /// Configuration for the maximum amount of lines to keep in memory. + max_scrollback: u32, +} + +impl ScreenBuffer { + /// Constructs a new `ScreenBuffer`. + /// + /// Takes the `width` and `height` of the terminal. + #[must_use] + pub fn new(rect: Rect) -> Self { + let mut buffer = Self { + lines: VecDeque::new(), + view_start: 0, + rect, + cursor: Position::ORIGIN, + selection_start: None, + selection_end: None, + max_scrollback: MAX_SCROLLBACK, + }; + // Start with an empty line + buffer.lines.push_back(Line::new_empty(rect.width.into())); + buffer + } + + pub(crate) const fn width(&self) -> u16 { + self.rect.width + } + + pub(crate) const fn height(&self) -> u16 { + self.rect.height + } + + pub(crate) fn push_line(&mut self, line: Line) { + self.lines.push_back(line); + } + + pub fn save_cursor_pos(&mut self, stdout: &mut W) { + use crossterm::cursor::SavePosition; + let _ = crossterm::execute!(stdout, SavePosition); + } + + pub fn restore_cursor_pos(&mut self, stdout: &mut W) { + use crossterm::cursor::RestorePosition; + let _ = crossterm::execute!(stdout, RestorePosition); + } + + pub(crate) fn handle_span_colors(&mut self, colors: &super::ColorState, attrs: Attributes) { + let curr_col = { + let c = self.to_buff(&self.cursor); + c.x.into() + }; + + let line = self.curr_line_mut(); + line.split_spans(colors, attrs, curr_col); + } + + pub(crate) fn with_current_span(&mut self, f: F) { + let buff_pos = self.to_buff(&self.cursor); + + let (span, offset) = { + let line = self.curr_line_mut(); + let (span_idx, col_offset) = line.span_at_col(buff_pos.x as usize); + (line.get_mut_span(span_idx).expect("verified"), col_offset) + }; + + f(span, offset); + } + + pub(crate) fn with_current_line)>( + &mut self, + f: F, + ) { + let buff_pos = self.to_buff(&self.cursor); + let line = self.curr_line_mut(); + + f(line, &buff_pos); + } + + pub(crate) fn curr_line(&self) -> Option<&Line> { + let pos_in_lines = self.to_buff(&self.cursor); + self.lines.get(pos_in_lines.y as usize) + } + + pub(crate) fn curr_line_mut(&mut self) -> &mut Line { + let pos_in_lines = self.to_buff(&self.cursor); + if self.lines.get(pos_in_lines.y as usize).is_some() { + self.lines + .get_mut(pos_in_lines.y as usize) + .expect("verified that line exists") + } else { + for _ in self.lines.len()..=pos_in_lines.y as usize { + self.push_line(Line::new_empty(self.width() as usize)); + } + self.lines + .get_mut(pos_in_lines.y as usize) + .expect("is not empty") + } + } + + pub(crate) fn buff_rect(&self) -> Rect { + Rect::from(( + (0_u16, self.view_start), + self.width(), + u32::from(self.height()), + )) + } + + #[allow(clippy::cast_possible_truncation)] + #[tracing::instrument(skip_all, level = "trace", name = "update_view")] + pub(crate) fn update_view(&mut self, update: Option) { + let buff_rect = self.buff_rect(); + let num_lines = self.lines.len(); + + let Some(_cmd) = update else { + // Return because still filling up the initial/empty screen 0..TERM_HEIGHT + if num_lines <= (self.view_start + buff_rect.height) as usize { + return; + } + + let additional = (num_lines as u32 - (self.view_start + buff_rect.height)).max(1); + self.view_start += additional; + return; + }; + + todo!(); + } + + pub(crate) fn insert_lines(&mut self, num: u16) { + let buff_pos = self.to_buff(&self.cursor); + for _ in 0..num { + self.lines.insert( + buff_pos.y as usize, + Line::new_empty(usize::from(self.width())), + ); + if self.lines.len() >= self.buff_rect().bottom() as usize { + self.lines.pop_back(); + } + } + self.cursor.x = 1; + } + + pub(crate) fn delete_lines(&mut self, num: u16) { + let buff_pos = self.to_buff(&self.cursor); + for _ in 0..num { + self.lines.remove(buff_pos.y as usize); + self.lines + .push_back(Line::new_empty(usize::from(self.width()))); + } + self.cursor.x = 1; + } + + #[allow(clippy::cast_possible_truncation)] + pub(crate) fn erase_in_display(&mut self, kind: EDKind) { + match kind { + EDKind::Below => { + let buff_range = std::ops::Range { + start: (self.to_buff(&self.cursor).y + 1) as usize, + end: (self.buff_rect().bottom() + 1) as usize, + }; + + self.clear_line_from_cursor(); + self.clear_lines(buff_range); + } + EDKind::Above => { + let buff_range = std::ops::Range { + start: self.view_start as usize, + end: self.to_buff(&self.cursor).y as usize, + }; + + self.clear_lines(buff_range); + self.clear_line_to_cursor(); + } + _ => { + self.lines + .push_back(Line::new_empty(usize::from(self.width()))); + // Casting to u32 from usize is fine because self.lines should never + // exceed MAX_SCROLLBACK which is < usize::MAX + self.view_start = (self.lines.len() as u32).saturating_sub(1); + self.cursor = Position::::ORIGIN; + } + } + } + + pub(crate) fn erase_in_line(&mut self, kind: ELKind) { + match kind { + ELKind::Right => { + self.clear_line_from_cursor(); + } + ELKind::Left => self.clear_line_to_cursor(), + ELKind::All => { + self.with_current_line(|line, _| { + line.iter_mut().flatten().for_each(|cell| { + cell.character = b' '; + }); + }); + } + } + } + + pub(crate) fn erase_chars(&mut self, num: u16) { + self.with_current_line(|line, cursor| { + line.iter_mut() + .flatten() + .skip(usize::from(cursor.x)) + .take(usize::from(num)) + .for_each(|c| c.character = b' '); + }); + } + + pub(crate) fn delete_chars(&mut self, num: u16) { + self.with_current_line(|line, cursor| { + let mut ttl_del = 0; + + if line.0.len() == 1 { + if let Some(span) = line.get_mut_span(0) { + ttl_del += span + .cells + .drain(usize::from(cursor.x)..usize::from(cursor.x + num)) + .len(); + span.cells.resize(span.len() + ttl_del, Cell::EMPTY); + } + } else { + let (start_span, start_off) = line.span_at_col(usize::from(cursor.x)); + let (end_span, end_off) = line.span_at_col(usize::from(cursor.x + num)); + + if start_span == end_span { + if let Some(span) = line.get_mut_span(start_span) { + ttl_del += span + .cells + .drain(usize::from(cursor.x)..usize::from(cursor.x + num)) + .len(); + span.cells.shrink_to(span.len()); + } + + if let Some(span) = line.get_mut_span(start_span + 1) { + span.cells.resize(span.len() + ttl_del, Cell::EMPTY); + } + } else if end_span - start_span == 1 { + if let Some(span) = line.get_mut_span(start_span) { + ttl_del += span.cells.drain(start_off..).len(); + span.cells.shrink_to(start_off); + } + + if let Some(span) = line.get_mut_span(end_span) { + ttl_del += span.cells.drain(..end_off).len(); + span.cells.resize(span.len() + ttl_del, Cell::EMPTY); + } + } else { + // remove any spans between start and end span + if let Some(span) = line.get_mut_span(start_span) { + ttl_del += span.cells.drain(start_off..).len(); + span.cells.shrink_to(start_off); + } + + for span_between in start_span + 1..end_span { + ttl_del += line.0.remove(span_between).cells.len(); + } + + if let Some(span) = line.get_mut_span(start_span + 1) { + ttl_del += span.cells.drain(..end_off).len(); + span.cells.resize(span.len() + ttl_del, Cell::EMPTY); + } + } + } + }); + } + + fn clear_line_to_cursor(&mut self) { + self.with_current_line(|line, cursor| { + for (idx, cell) in line.iter_mut().flatten().enumerate() { + if idx < usize::from(cursor.x) { + cell.character = b' '; + } + } + }); + } + + fn clear_line_from_cursor(&mut self) { + self.with_current_line(|line, cursor| { + line.iter_mut() + .flatten() + .skip(usize::from(cursor.x)) + .for_each(|cell| { + cell.character = b' '; + }); + }); + } + + fn clear_lines(&mut self, range: std::ops::Range) { + for line in self.lines.range_mut(range) { + line.iter_mut().flatten().for_each(|cell| { + cell.character = b' '; + }); + } + } + + pub(crate) fn scroll_down(&mut self, num: u16) { + for _ in 0..num { + self.lines.insert( + self.view_start as usize, + Line::new_empty(usize::from(self.width())), + ); + if self.lines.len() >= self.buff_rect().bottom() as usize { + self.lines.pop_back(); + } + } + } + + pub(crate) fn scroll_up(&mut self, num: u16) { + for _ in 0..num { + self.view_start += 1; + self.lines + .push_back(Line::new_empty(usize::from(self.width()))); + } + } +} diff --git a/sericom-core/src/screen/components/cell.rs b/sericom-core/src/screen/components/cell.rs new file mode 100644 index 0000000..0e75b0b --- /dev/null +++ b/sericom-core/src/screen/components/cell.rs @@ -0,0 +1,102 @@ +use std::ops::{Deref, DerefMut}; + +/// `Cell` represents a cell within the terminal's window/frame. +/// +/// Used to hold rendering state for all the cells within the [`ScreenBuffer`][`super::ScreenBuffer`]. +/// Each line within [`ScreenBuffer`][`super::ScreenBuffer`] is represented by a `Vec`. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Cell { + /// Stored as u8 for memory size optimization (1 byte vs 4) + /// + /// [`Cell`] is only constructed from valid ascii characters from the [`ByteParser`] + /// and since it is only ascii characters, it is safe to store as [`u8`] and + /// later cast as char. This reduces the size of [`Cell`] from 8 to 2 (if [`char`] + /// were used instead of [`u8`]). + pub(crate) character: u8, + pub(crate) is_selected: bool, +} + +impl Cell { + pub const CARRIGE: Self = Self::new(b'\r'); + pub const EMPTY: Self = Self::new(b' '); + pub const NEWLINE: Self = Self::new(b'\n'); + pub const TAB: Self = Self::new(b'\t'); + + #[must_use] + pub const fn new(character: u8) -> Self { + Self { + character, + is_selected: false, + } + } +} + +impl Deref for Cell { + type Target = u8; + + fn deref(&self) -> &Self::Target { + &self.character + } +} + +impl DerefMut for Cell { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.character + } +} + +impl From for Cell { + /// WARNING: This will have unexpected behavior if the `char` is _NOT_ valid ASCII + fn from(value: char) -> Self { + Self::new(value as u8) + } +} + +impl From<&char> for Cell { + /// WARNING: This will have unexpected behavior if the `char` is _NOT_ valid ASCII + fn from(value: &char) -> Self { + Self::new(*value as u8) + } +} + +impl From for Cell { + fn from(value: u8) -> Self { + Self::new(value) + } +} + +impl From<&u8> for Cell { + fn from(value: &u8) -> Self { + Self::new(*value) + } +} + +impl From for char { + fn from(value: Cell) -> Self { + value.character as Self + } +} + +impl From for u8 { + fn from(value: Cell) -> Self { + value.character + } +} + +impl Default for Cell { + /// Default is a [space] char (' ') and is_selected = false + fn default() -> Self { + Self { + character: b' ', + is_selected: false, + } + } +} + +impl<'a> FromIterator<&'a Cell> for std::string::String { + fn from_iter>(iter: T) -> Self { + iter.into_iter() + .map(|c| c.character as char) + .collect::() + } +} diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs new file mode 100644 index 0000000..a07b554 --- /dev/null +++ b/sericom-core/src/screen/components/line.rs @@ -0,0 +1,299 @@ +use std::ops::{Index, IndexMut}; + +use crossterm::style::{Attributes, SetAttributes, SetColors}; + +use crate::screen::ColorState; + +use super::Span; + +/// Line is a wrapper around [`Vec`] and represents a line within the [`ScreenBuffer`][`super::super::ScreenBuffer`]. +#[derive(Clone, Eq, PartialEq, Default)] +pub struct Line(pub Vec); + +impl Line { + /// Create a new [`Line`] with a single [`Span`] with the length of `width`. + /// + /// The [`Span`] is filled with [`Cell::EMPTY`], default [`Attributes`] and [`Colors`]. + #[must_use] + pub fn new_empty(width: usize) -> Self { + Self(vec![Span::new_empty(width, None, None); 1]) + } + + /// Util function to return the length of `self`. + /// + /// **Note:** This returns the length as in the number of [`Span`]s _not_ [`Cell`]s. + #[must_use] + #[allow(clippy::len_without_is_empty)] + pub const fn len(&self) -> usize { + self.0.len() + } + + /// Iterates over the [`Cell`]s and resets their selected state. + pub fn clear_selection(&mut self) { + self.0 + .iter_mut() + .flatten() + .for_each(|cell| cell.is_selected = false); + } + + /// Returns a reference to the [`Span`] at `idx`. + #[must_use] + pub fn get_span(&self, idx: usize) -> Option<&Span> { + self.0.get(idx) + } + + /// Returns a mutable reference to the [`Span`] at `idx`. + pub fn get_mut_span(&mut self, idx: usize) -> Option<&mut Span> { + self.0.get_mut(idx) + } + + /// Returns the index of the [`Span`] and the offset within the [`Span`] for `col` + #[must_use] + pub fn span_at_col(&self, col: usize) -> (usize, usize) { + if self.0.len() == 1 { + return (0, col); + } + + let mut acc = 0; + for (idx, span) in self.0.iter().enumerate() { + let end = acc + span.len(); + if col < end { + return (idx, col - acc); + } + acc = end; + } + (self.0.len().saturating_sub(1), self.0.last().unwrap().len()) + } + + /// The total number of [`Cell`]s in `self`, where [`Cell`] != ' '. + /// + /// **Note:** This number includes whitespace between words since that is + /// generally the desired behavior. + #[must_use] + pub fn filled_cells(&self) -> usize { + let last = self.last_filled_idx(); + if last == 0 { 0 } else { last + 1 } + } + + /// The total number of [`Cell`]s in `self`. + #[must_use] + pub fn num_cells(&self) -> usize { + self.iter().flatten().count() + } + + /// The index of the last [`Cell`] in `self` where [`Cell`] != ' ' (whitespace). + #[must_use] + pub fn last_filled_idx(&self) -> usize { + if self.all_whitespace() { + return 0; + } + + let len = self.num_cells(); + let p = self + .iter() + .flatten() + .rev() + .position(|c| c.character != b' ') + .unwrap_or(0); + if len - p == len { len - 1 } else { len - p - 1 } + } + + fn all_whitespace(&self) -> bool { + for c in self.iter().flatten() { + if **c != b' ' { + return false; + } + } + true + } + + /// Whether [`Line`] contains zero _[`Span`]s_. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn iter(&self) -> std::slice::Iter<'_, Span> { + self.0.iter() + } + + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Span> { + self.0.iter_mut() + } + + /// Push a [`Span`] to `self`. + pub fn push(&mut self, span: Span) { + self.0.push(span); + } + + pub fn ascii_bytes(&self) -> impl Iterator + '_ { + use std::ops::Deref; + + let end = { + let last = self.last_filled_idx(); + if last == 0 && *self.0[0].cells[0] == b' ' { + return EitherIter::Left(std::iter::once(b'\n')); + } + last + 1 + }; + + EitherIter::Right( + self.iter() + .flatten() + .take(end) + .map(Deref::deref) + .copied() + .chain(std::iter::once(b'\n')), + ) + } + + /// Splits the [`Span`] at `col` and applies `colors` && `attrs` to the new [`Span`]. + /// + /// If there is only a single [`Span`] in `self` and [`Self::num_filled_cells()`] == 0 + /// then this will not split the [`Span`] or create a new one. Instead it will + /// just apply the [`ColorState`] and [`Attributes`] to that [`Span`]. + pub fn split_spans(&mut self, colors: &ColorState, attrs: Attributes, col: usize) { + // Handles the case where an ESC[ is the first input for an empty line + if self.len() == 1 + && self.filled_cells() == 0 + && let Some(span) = self.get_mut_span(0) + { + span.set_colors(colors); + span.set_attrs(attrs); + tracing::trace!(target: "line::split::first", ?span); + return; + } + + let (span_idx, offset) = self.span_at_col(col); + let Some(span) = self.get_mut_span(span_idx) else { + tracing::trace!(target: "line::split", %col, %span_idx, "FAILED TO GET SPAN"); + return; + }; + tracing::trace!(target: "line::split::rest", ?span); + let mut new = span.split_at(offset); + new.set_colors(colors); + new.set_attrs(attrs); + self.push(new); + } +} + +impl IntoIterator for Line { + type Item = Span; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a> IntoIterator for &'a Line { + type Item = &'a Span; + type IntoIter = std::slice::Iter<'a, Span>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl<'a> IntoIterator for &'a mut Line { + type Item = &'a mut Span; + type IntoIter = std::slice::IterMut<'a, Span>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter_mut() + } +} + +impl Index for Line { + type Output = Span; + fn index(&self, index: usize) -> &Self::Output { + &self.0[index] + } +} + +impl IndexMut for Line { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + &mut self.0[index] + } +} + +impl crossterm::Command for Line { + #[cfg(windows)] + fn execute_winapi(&self) -> Result<(), std::io::Error> { + todo!() + } + + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + let mut spans = self.iter(); + let Some(first) = spans.next() else { + return Ok(()); + }; + + let mut colors = first.colors; + let mut attrs = first.attrs; + + SetColors(colors).write_ansi(f)?; + SetAttributes(attrs).write_ansi(f)?; + first.write_ansi(f)?; + + for span in spans { + if span.colors != colors { + SetColors(span.colors).write_ansi(f)?; + colors = span.colors; + } + + if span.attrs != attrs { + SetAttributes(span.attrs).write_ansi(f)?; + attrs = span.attrs; + } + + span.write_ansi(f)?; + } + + Ok(()) + } +} + +impl std::fmt::Debug for Line { + /// Prints "Line [cells: {#}] ( {spans} )" + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write; + + let mut s = format!("Line [cells: {}] ( ", self.num_cells()); + for span in self { + let _ = write!(s, "{span:?}"); + } + s.push_str(" )"); + f.write_str(&s) + } +} + +/// A small helper enum - literally only for short-circuiting `Line::ascii_bytes` +enum EitherIter { + Left(L), + Right(R), +} + +impl Iterator for EitherIter +where + L: Iterator, + R: Iterator, +{ + type Item = T; + + #[inline] + fn next(&mut self) -> Option { + match self { + Self::Left(l) => l.next(), + Self::Right(r) => r.next(), + } + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + match self { + Self::Left(l) => l.size_hint(), + Self::Right(r) => r.size_hint(), + } + } +} diff --git a/sericom-core/src/screen/components/mod.rs b/sericom-core/src/screen/components/mod.rs new file mode 100644 index 0000000..1e5e00e --- /dev/null +++ b/sericom-core/src/screen/components/mod.rs @@ -0,0 +1,7 @@ +mod cell; +mod line; +mod span; + +pub use cell::Cell; +pub use line::Line; +pub use span::Span; diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs new file mode 100644 index 0000000..0eed643 --- /dev/null +++ b/sericom-core/src/screen/components/span.rs @@ -0,0 +1,275 @@ +use std::ops::{Index, IndexMut}; + +use crossterm::{ + Command, + style::{Attribute, Attributes, Color, Colors}, +}; + +use crate::{ + configs::get_config, + screen::{Cell, process::ColorState}, +}; + +#[derive(Clone, Eq, PartialEq)] +pub struct Span { + pub(crate) cells: Vec, + pub(crate) attrs: Attributes, + pub(crate) colors: Colors, +} + +impl Default for Span { + fn default() -> Self { + let colors = Self::get_config_colors(); + Self { + cells: Vec::default(), + attrs: Attributes::default(), + colors, + } + } +} + +impl Span { + /// Splits `self` [0, idx) and returns a new [`Span`] [idx, len). + /// + /// The returned [`Span`] has the same properties (colors and attributes) as `self`. + pub(crate) fn split_at(&mut self, idx: usize) -> Self { + Self { + cells: self.cells.split_off(idx), + colors: self.colors, + attrs: self.attrs, + } + } + + /// The index of the last [`Cell`] in `self` that is not whitespace. + #[must_use] + pub fn last_filled_idx(&self) -> usize { + if self.all_whitespace() { + return 0; + } + + let len = self.len(); + let p = self + .iter() + .rev() + .position(|c| c.character != b' ') + .unwrap_or(0); + if len - p == len { len - 1 } else { len - p } + } + + fn all_whitespace(&self) -> bool { + for c in &self.cells { + if **c != b' ' { + return false; + } + } + true + } + + fn get_config_colors() -> Colors { + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + Colors::new(fg, bg) + } + + /// Set the [`Attributes`] for `self`. + pub const fn set_attrs(&mut self, attrs: Attributes) { + self.attrs = attrs; + } + + /// Add an [`Attribute`] to [`Self`], if already set this does nothing. + pub fn add_attr(&mut self, attr: Attribute) { + self.attrs.set(attr); + } + + /// Reset all [`Cell`]s within `self`. + /// + /// Sets every [`Cell`] in [`Self`] to [`Cell::EMPTY`], sets [`Attributes`] + /// to [`Attributes::default()`] and [`Colors`] to those from [`Config::appearance`]. + /// + /// [`Config::appearance`]: `crate::configs::Config::appearance` + pub(crate) fn reset(&mut self) { + self.cells.iter_mut().for_each(|cell| { + *cell = Cell::EMPTY; + }); + self.attrs = Attributes::default(); + self.colors = Self::get_config_colors(); + } + + /// Creates a new [`Span`] filled with [`Cell::EMPTY`] to `width`. + /// + /// Create the [`Span`] with [`Colors`] and/or [`Attributes`]. If `None`, + /// uses colors from config file ([`get_config()`]) and [`Attributes::default()`]. + pub fn new_empty(width: usize, colors: Option, attrs: Option) -> Self { + let colors = colors.unwrap_or_else(Self::get_config_colors); + let attrs = attrs.unwrap_or_default(); + Self { + cells: vec![Cell::EMPTY; width], + attrs, + colors, + } + } + + /// Creates a new [`Span`] and reserves space for `width` of [`Cell`]s. + /// + /// This calls [`Vec::with_capacity()`] and does not create any [`Cell`]s. + /// Create the [`Span`] with `colors` and/or `attrs`. If `None`, uses colors + /// from config file ([`get_config()`]) and [`Attributes::default()`]. + pub(crate) fn reserve_new( + width: usize, + colors: Option, + attrs: Option, + ) -> Self { + let colors = colors.unwrap_or_else(Self::get_config_colors); + let attrs = attrs.unwrap_or_default(); + Self { + cells: Vec::with_capacity(width), + attrs, + colors, + } + } + + /// Resizes `self` to `width` with [`Cell::EMPTY`]. + pub(crate) fn fill_to_width(&mut self, width: usize) { + self.cells.resize(width, Cell::EMPTY); + } + + /// Shrinks `self` to the last filled [`Cell`], think [`str::trim_end()`]. + /// + /// Finds the last [`Cell`] in `self` that is not whitespace and truncates + /// `self` to that index, dropping any [`Cell`]s past it, and then calls + /// [`shrink_to()`] that same index to then truncate `self`s capacity. + /// + /// [`shrink_to()`]: `Vec::shrink_to()` + pub fn shrink(&mut self) { + let size = self.last_filled_idx(); + self.cells.truncate(size); + self.cells.shrink_to(size); + } + + /// Push a [`Cell`] to `self`. + pub(crate) fn push(&mut self, cell: Cell) { + self.cells.push(cell); + } + + /// Length of [`Cell`]s in `self`. + pub(crate) const fn len(&self) -> usize { + self.cells.len() + } + + /// Set the [`Colors`] for `self`. + pub(crate) const fn set_colors(&mut self, colors: &ColorState) { + self.colors = colors.get_colors(); + } + + pub(crate) const fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + pub fn iter(&self) -> std::slice::Iter<'_, Cell> { + self.cells.iter() + } + + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Cell> { + self.cells.iter_mut() + } + + pub(crate) const fn colors(&self) -> Colors { + self.colors + } + + pub(crate) const fn attrs(&self) -> Attributes { + self.attrs + } +} + +impl Command for Span { + #[cfg(windows)] + fn execute_winapi(&self) -> Result<(), std::io::Error> { + todo!() + } + + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + f.write_str(&String::from_iter(&self.cells)) + } +} + +impl IntoIterator for Span { + type Item = Cell; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.cells.into_iter() + } +} + +impl<'a> IntoIterator for &'a Span { + type Item = &'a Cell; + type IntoIter = std::slice::Iter<'a, Cell>; + + fn into_iter(self) -> Self::IntoIter { + self.cells.iter() + } +} + +impl<'a> IntoIterator for &'a mut Span { + type Item = &'a mut Cell; + type IntoIter = std::slice::IterMut<'a, Cell>; + + fn into_iter(self) -> Self::IntoIter { + self.cells.iter_mut() + } +} + +impl Index for Span { + type Output = Cell; + fn index(&self, index: usize) -> &Self::Output { + &self.cells[index] + } +} + +impl IndexMut for Span { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + &mut self.cells[index] + } +} + +impl FromIterator for Span { + fn from_iter>(iter: T) -> Self { + let colors = Self::get_config_colors(); + Self { + cells: iter.into_iter().map(Cell::from).collect(), + colors, + attrs: Attributes::default(), + } + } +} + +impl FromIterator for Span { + fn from_iter>(iter: T) -> Self { + let colors = Self::get_config_colors(); + Self { + cells: iter.into_iter().collect(), + colors, + attrs: Attributes::default(), + } + } +} + +impl std::fmt::Debug for Span { + /// Prints "Span[fg: {:?}, bg: {:?}, attrs: {:?}, len: {}, cap: {}] ( {cells} )" + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut s = format!( + "Span[fg: {:?}, bg: {:?}, attrs: {:?}, len: {}, cap: {}] ( ", + self.colors.foreground.unwrap(), + self.colors.background.unwrap(), + self.attrs, + self.cells.len(), + self.cells.capacity() + ); + s.extend(self.cells.iter().map(|c| c.character as char)); + s.push(')'); + f.write_str(&s) + } +} diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs new file mode 100644 index 0000000..0feb345 --- /dev/null +++ b/sericom-core/src/screen/mod.rs @@ -0,0 +1,38 @@ +//! This module contains the code needed for the implementation of a +//! stateful buffer that holds a history of the lines/data received +//! from the serial connection and the rendering/updating of the buffer +//! to the terminal screen (stdout). +//! +//! Simply writing the data received from the serial connection directly +//! to stdout creates one main issue: there is no history of previous lines +//! that were received from the serial connection. Without a screen buffer, +//! lines would simply be wiped from existence as they exit the terminal's screen. +//! +//! As a result, there would be no way to implement features like scrolling, +//! highlighting text (for UI purposes), and getting characters at specific +//! locations within the screen for things like copying to a clipboard. +//! +//! The screen buffer solves these issues by storing each line received from the +//! connection in a [`VecDeque`]. It is important to note that +//! currently, the **capacity of the [`VecDeque`] is hardcoded with a value of 10,000 +//! lines with [`MAX_SCROLLBACK`]**. +// #![allow(dead_code)] +// #![allow(unused)] + +mod buffer; +mod components; +mod position; +pub mod process; +mod rect; +mod render; +mod ui_command; + +pub use buffer::ScreenBuffer; +pub use components::{Cell, Line, Span}; +pub use position::{BuffPos, Cursor, PosType, PosY, Position, Scope, TermPos, TranslatePos}; +pub use process::{ByteParser, ColorState, ParseState, ParserEvent, ScreenDriver}; +pub use rect::Rect; +pub use ui_command::{UIAction, UICommand}; + +#[cfg(test)] +pub(crate) mod tests; diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs new file mode 100644 index 0000000..c3a7873 --- /dev/null +++ b/sericom-core/src/screen/position.rs @@ -0,0 +1,317 @@ +use std::{fmt::Display, marker::PhantomData}; + +use super::Rect; +use super::ScreenBuffer; + +const TAB_WIDTH: u16 = 8; + +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] +pub struct TermPos; +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] +pub struct BuffPos; + +pub trait Scope {} +impl Scope for TermPos {} +impl Scope for BuffPos {} + +pub trait PosType { + type Y: Copy + + std::hash::Hash + + From + + std::fmt::Display + + Clone + + Default + + Eq + + PartialEq + + std::fmt::Debug + + From; +} + +impl PosType for TermPos { + type Y = u16; +} +impl PosType for BuffPos { + type Y = u32; +} + +pub type PosY = ::Y; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub struct Position { + pub(crate) x: u16, + pub(crate) y: PosY, + _phantom: PhantomData, +} + +impl Position { + pub const ORIGIN: Self = Self { + x: 1, + y: 1, + _phantom: PhantomData, + }; +} + +impl Position { + /// Forward tab _n_ times. + pub const fn tabn(&mut self, n: u16) { + let mut acc = 0; + while acc < n { + self.tab(); + acc += 1; + } + } + + /// Reverse tab _n_ times. + pub const fn rtabn(&mut self, n: u16) { + let mut acc = 0; + while acc < n { + self.rtab(); + acc += 1; + } + } + + /// Forward tab. + pub const fn tab(&mut self) { + let tab = TAB_WIDTH - ((self.x - 1) % TAB_WIDTH); + self.x += tab; + } + + /// Reverse tab. + pub const fn rtab(&mut self) { + let tab = TAB_WIDTH - (self.x % TAB_WIDTH); + self.x = self.x.saturating_sub(tab); + } + + pub const fn set_y(&mut self, y: PosY) { + self.y = y; + } + + pub const fn set_x(&mut self, x: u16) { + self.x = x; + } + + pub fn set_pos_from>(&mut self, pos: P) { + let p = pos.into(); + self.x = p.x; + self.y = p.y; + } + + pub const fn set(&mut self, pos: Self) { + *self = pos; + } + + pub const fn x(&self) -> u16 { + self.x + } + + pub const fn y(&self) -> PosY { + self.y + } +} + +impl Default for Position +where + S: Scope + PosType, + ::Y: Default, +{ + fn default() -> Self { + Self { + x: 0, + y: Default::default(), + _phantom: PhantomData, + } + } +} + +impl Display for Position { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +macro_rules! impl_from { + ($from:ty, $for:path) => { + impl From<$from> for $for { + fn from((x, y): $from) -> Self { + Self { + x, + y, + _phantom: PhantomData, + } + } + } + }; + ($from:ty, $for:path, y_as = $as:ty) => { + impl From<$from> for $for { + fn from((x, y): $from) -> Self { + Self { + x, + y: <$as>::try_from(y).expect("usize is within the height of terminal"), + _phantom: PhantomData, + } + } + } + }; + ($from:ty, $for:path, x_as = $as:ty) => { + impl From<$from> for $for { + fn from((x, y): $from) -> Self { + Self { + x: <$as>::try_from(x).expect("usize is within the width of terminal"), + y, + _phantom: PhantomData, + } + } + } + }; +} + +//-- TermPos --// +impl_from!((u16, u16), Position); +impl_from!((u16, usize), Position, y_as = u16); +impl_from!((usize, u16), Position, x_as = u16); + +// -- BuffPos --// +impl_from!((u16, u32), Position); +impl_from!((u16, usize), Position, y_as = u32); + +pub trait HasBounds { + type Bounds; + fn bounds(&self) -> Self::Bounds; +} + +impl HasBounds for ScreenBuffer { + type Bounds = Rect; + + fn bounds(&self) -> Self::Bounds { + self.rect + } +} + +/// Coordinates movement within the implementor based on the implementor's bounds. +/// +/// Decide what the implementor's bounds are, and implement [`HasBounds`]. Then +/// this trait can be implemented once and provide uniform movement logic throughout +/// your code based on how the movement should behave within your type's bounds. +pub trait Cursor: HasBounds { + /// Set both the x and y of the cursor's position. + fn set_cursor_pos

(&mut self, position: P) + where + P: Into>; + /// Move the cursor left relative to it's current y position. + fn move_cursor_left(&mut self, cells: u16); + /// Move the cursor right relative to it's current x position. + fn move_cursor_right(&mut self, cells: u16); + /// Move the cursor up relative to it's current y position. + fn move_cursor_up(&mut self, lines: u16); + /// Move the cursor down relative to it's current y position. + fn move_cursor_down(&mut self, lines: u16); + /// Set the cursor's x position directly. + /// + /// Useful when the movement is not relative to the cursor's current + /// position, but a direct/absolute position. + fn set_cursor_col(&mut self, col: u16); + /// Set the cursor's y position directly. + /// + /// Useful when the movement is not relative to the cursor's current + /// position, but a direct/absolute position. + fn set_cursor_row(&mut self, row: u16); + fn tab(&mut self, n: u16); + fn rtab(&mut self, n: u16); +} + +impl Cursor for ScreenBuffer { + fn tab(&mut self, n: u16) { + let bounds = self.bounds(); + self.cursor.tabn(n); + self.cursor.x = self.cursor.x.min(bounds.right()); + } + + fn rtab(&mut self, n: u16) { + self.cursor.rtabn(n); + self.cursor.x = self.cursor.x.max(1); + } + + fn set_cursor_pos

(&mut self, position: P) + where + P: Into>, + { + let bounds = self.bounds(); + let mut new_pos: Position = position.into(); + + new_pos.x = new_pos.x.clamp(1, bounds.right()); + new_pos.y = new_pos.y.clamp(1, bounds.bottom()); + + self.cursor.set_pos_from(new_pos); + } + + fn move_cursor_left(&mut self, cells: u16) { + self.cursor.x = self.cursor.x.saturating_sub(cells).max(1); + } + + fn move_cursor_right(&mut self, cells: u16) { + let bounds = self.bounds(); + self.cursor.x = self.cursor.x.saturating_add(cells).min(bounds.right()); + } + + fn move_cursor_up(&mut self, lines: u16) { + self.cursor.y = self.cursor.y.saturating_sub(lines).max(1); + } + + fn move_cursor_down(&mut self, lines: u16) { + let bounds = self.bounds(); + // If the cursor would pass the bottom scroll margin, it will stop there + // [xterm.js](https://xtermjs.org/docs/api/vtfeatures/) + self.cursor.y = self.cursor.y.saturating_add(lines).min(bounds.bottom()); + } + + fn set_cursor_col(&mut self, col: u16) { + let bounds = self.bounds(); + self.cursor.x = col.clamp(1, bounds.right()); + } + + fn set_cursor_row(&mut self, row: u16) { + let bounds = self.bounds(); + self.cursor.y = row.clamp(1, bounds.bottom()); + } +} + +pub trait TranslatePos { + fn to_term(&self, pos: &Position) -> Position; + fn to_buff(&self, pos: &Position) -> Position; +} + +impl TranslatePos for ScreenBuffer { + fn to_term(&self, pos: &Position) -> Position { + let buff_win = self.buff_rect(); + let visible_y = pos.y.clamp(buff_win.top(), buff_win.bottom()); + + // Casting is fine because the viewport (buff_win) is the size of + // a user's terminal and visible_y - buff_win.top() simply returns + // a number somewhere within the height of the terminal + #[allow(clippy::cast_possible_truncation)] + let term_y = ((visible_y - buff_win.top()) as u16).clamp(1, buff_win.height as u16); + let term_x = pos.x.clamp(1, buff_win.right()); + + Position::::from((term_x, term_y)) + } + + fn to_buff(&self, pos: &Position) -> Position { + let buff_y: u32 = { + if pos.y == 1 { + self.view_start + } else { + self.view_start + u32::from(pos.y - 1) + } + }; + + let buff_x = { + if pos.x == 1 { + 0 + } else { + pos.x.clamp(0, self.width()).saturating_sub(1).max(1) + } + }; + + Position::::from((buff_x, buff_y)) + } +} diff --git a/sericom-core/src/screen/process/colors.rs b/sericom-core/src/screen/process/colors.rs new file mode 100644 index 0000000..71962ff --- /dev/null +++ b/sericom-core/src/screen/process/colors.rs @@ -0,0 +1,187 @@ +use crossterm::{ + queue, + style::{Attribute, Attributes, Color, Colored, Colors, Print, SetColors}, +}; +use miette::IntoDiagnostic; +use std::io::Write; + +use super::SEMI; +use crate::configs::get_config; + +#[derive(Debug)] +pub struct ColorState { + colors: Colors, +} + +impl Default for ColorState { + fn default() -> Self { + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + Self { + colors: Colors::new(fg, bg), + } + } +} + +impl ColorState { + #[must_use] + pub const fn get_colors(&self) -> Colors { + self.colors + } + pub fn queue_line(&self, writer: &mut W, text: &str) -> miette::Result<()> { + queue!(writer, SetColors(self.colors), Print(text)).into_diagnostic()?; + Ok(()) + } + pub fn set_fg(&mut self, color: Color) { + self.colors = Colors::new(color, self.colors.background.unwrap_or(Color::Reset)); + } + pub fn set_bg(&mut self, color: Color) { + self.colors = Colors::new(self.colors.foreground.unwrap_or(Color::Reset), color); + } + pub fn reset(&mut self) { + self.colors = Colors::new(Color::Reset, Color::Reset); + } + pub fn set_colors(&mut self, ascii_str: &str) { + use tracing::debug; + + self.colors = if let Some(colored) = Colored::parse_ansi(ascii_str) { + debug!("{colored:#?}"); + self.colors.then(&colored.into()) + } else { + if let Some(color) = Color::parse_ansi(ascii_str) { + debug!("Second try got: {color:#?}"); + } else { + debug!("Failed to parse ascii_str second time"); + } + debug!("Failed to parse ascii_str"); + self.colors + }; + } +} + +pub fn process_colors(params: &[u8], color_state: &mut ColorState, attrs: &mut Attributes) { + let mut body_idx = 0; + let mut parts_iter = params.split(|&p| p == SEMI).peekable(); + + while let Some(part) = parts_iter.next() { + match part { + // Give any 38;5;26 / 48;2;50;60;70 sequences to crossterm + [b'3' | b'4', b'8'] => { + if let Some(ident) = parts_iter.next_if(|n| *n == [b'5']) { + let Some(color) = parts_iter.next() else { + break; + }; + + // Get slice from body (+ 1 for the separators ';') + let part_str_len = part.len() + 1 + ident.len() + 1 + color.len(); + let slice = ¶ms[body_idx..body_idx + part_str_len]; + + if let Ok(s) = std::str::from_utf8(slice) { + color_state.set_colors(s); + } + + // add to body_idx the indexes we consumed + body_idx += part_str_len - part.len(); + } else if let Some(ident) = parts_iter.next_if(|n| *n == [b'2']) { + let Some(r) = parts_iter.next() else { break }; + let Some(g) = parts_iter.next() else { break }; + let Some(b) = parts_iter.next() else { break }; + + // get slice from body (+ 1 for the separators ';') + let part_str_len = + part.len() + 1 + ident.len() + 1 + r.len() + 1 + g.len() + 1 + b.len(); + let slice = ¶ms[body_idx..body_idx + part_str_len]; + + if let Ok(s) = std::str::from_utf8(slice) { + color_state.set_colors(s); + } + + // add to body_idx the indexes we consumed + body_idx += part_str_len - part.len(); + } + } + _ => handle_colors_and_attrs(part, color_state, attrs), + } + // + 1 for the separator + body_idx += part.len() + 1; + } +} + +fn handle_colors_and_attrs(body: &[u8], color_state: &mut ColorState, attrs: &mut Attributes) { + match *body { + // Attribute sgr mapping to crossterm + [b'0'] => { + *color_state = ColorState::default(); // uses config colors + *attrs = Attributes::default(); + } + [b'1'] => attrs.set(Attribute::Bold), + [b'2'] => attrs.set(Attribute::Dim), + [b'3'] => attrs.set(Attribute::Italic), + [b'4'] => attrs.set(Attribute::Underlined), + [b'4', b':', b'2'] => attrs.set(Attribute::DoubleUnderlined), + [b'4', b':', b'3'] => attrs.set(Attribute::Undercurled), + [b'4', b':', b'4'] => attrs.set(Attribute::Underdotted), + [b'4', b':', b'5'] => attrs.set(Attribute::Underdashed), + [b'5'] => attrs.set(Attribute::SlowBlink), + [b'6'] => attrs.set(Attribute::RapidBlink), + [b'7'] => attrs.set(Attribute::Reverse), + [b'8'] => attrs.set(Attribute::Hidden), + [b'9'] => attrs.set(Attribute::CrossedOut), + [b'2', b'0'] => attrs.set(Attribute::Fraktur), + [b'2', b'1'] => attrs.set(Attribute::NoBold), + [b'2', b'2'] => attrs.set(Attribute::NormalIntensity), + [b'2', b'3'] => attrs.set(Attribute::NoItalic), + [b'2', b'4'] => attrs.set(Attribute::NoUnderline), + [b'2', b'5'] => attrs.set(Attribute::NoBlink), + [b'2', b'7'] => attrs.set(Attribute::NoReverse), + [b'2', b'8'] => attrs.set(Attribute::NoHidden), + [b'2', b'9'] => attrs.set(Attribute::NotCrossedOut), + [b'5', b'1'] => attrs.set(Attribute::Framed), + [b'5', b'2'] => attrs.set(Attribute::Encircled), + [b'5', b'3'] => attrs.set(Attribute::OverLined), + [b'5', b'4'] => attrs.set(Attribute::NotFramedOrEncircled), + [b'5', b'5'] => attrs.set(Attribute::NotOverLined), + // Basic 8 foreground + [b'3', b'0'] => color_state.set_fg(Color::Black), + [b'3', b'1'] => color_state.set_fg(Color::DarkRed), + [b'3', b'2'] => color_state.set_fg(Color::DarkGreen), + [b'3', b'3'] => color_state.set_fg(Color::DarkYellow), + [b'3', b'4'] => color_state.set_fg(Color::DarkBlue), + [b'3', b'5'] => color_state.set_fg(Color::DarkMagenta), + [b'3', b'6'] => color_state.set_fg(Color::DarkCyan), + [b'3', b'7'] => color_state.set_fg(Color::Grey), + // Basic 8 background + [b'4', b'0'] => color_state.set_bg(Color::Black), + [b'4', b'1'] => color_state.set_bg(Color::DarkRed), + [b'4', b'2'] => color_state.set_bg(Color::DarkGreen), + [b'4', b'3'] => color_state.set_bg(Color::DarkYellow), + [b'4', b'4'] => color_state.set_bg(Color::DarkBlue), + [b'4', b'5'] => color_state.set_bg(Color::DarkMagenta), + [b'4', b'6'] => color_state.set_bg(Color::DarkCyan), + [b'4', b'7'] => color_state.set_bg(Color::Grey), + // Basic 8 bright foreground + [b'9', b'0'] => color_state.set_fg(Color::DarkGrey), + [b'9', b'1'] => color_state.set_fg(Color::Red), + [b'9', b'2'] => color_state.set_fg(Color::Green), + [b'9', b'3'] => color_state.set_fg(Color::Yellow), + [b'9', b'4'] => color_state.set_fg(Color::Blue), + [b'9', b'5'] => color_state.set_fg(Color::Magenta), + [b'9', b'6'] => color_state.set_fg(Color::Cyan), + [b'9', b'7'] => color_state.set_fg(Color::White), + // Basic 8 bright background + [b'1', b'0', b'0'] => color_state.set_bg(Color::DarkGrey), + [b'1', b'0', b'1'] => color_state.set_bg(Color::Red), + [b'1', b'0', b'2'] => color_state.set_bg(Color::Green), + [b'1', b'0', b'3'] => color_state.set_bg(Color::Yellow), + [b'1', b'0', b'4'] => color_state.set_bg(Color::Blue), + [b'1', b'0', b'5'] => color_state.set_bg(Color::Magenta), + [b'1', b'0', b'6'] => color_state.set_bg(Color::Cyan), + [b'1', b'0', b'7'] => color_state.set_bg(Color::White), + // Set colors to default + [b'3', b'9'] => color_state.set_fg(Color::Reset), + [b'4', b'9'] => color_state.set_bg(Color::Reset), + _ => {} // Ignore rest + } +} diff --git a/sericom-core/src/screen/process/driver.rs b/sericom-core/src/screen/process/driver.rs new file mode 100644 index 0000000..e034b7c --- /dev/null +++ b/sericom-core/src/screen/process/driver.rs @@ -0,0 +1,212 @@ +use crossterm::style::Attributes; +use tracing::{debug, trace}; + +use super::{C0, C1, CSI, ColorState, CsiKind, ParserEvent, SEMI, digits_to_int, process_colors}; +use crate::screen::{ + Cell, Cursor as _, Line, ScreenBuffer, + process::{EDKind, ELKind}, +}; + +/// The layer between incoming [`ParserEvent`]s and the [`ScreenBuffer`]. +pub struct ScreenDriver<'a> { + buffer: &'a mut ScreenBuffer, + color_state: ColorState, + attrs: Attributes, +} + +impl<'a> ScreenDriver<'a> { + pub fn new(buffer: &'a mut ScreenBuffer) -> Self { + Self { + buffer, + color_state: ColorState::default(), + attrs: Attributes::default(), + } + } + + /// Returns the number of newlines written + pub fn process_events(&mut self, events: Vec>) -> u32 { + let span = tracing::trace_span!("process"); + let _enter = span.enter(); + + let mut newlines = 0; + for event in events { + trace!(%event); + match event { + ParserEvent::Text(bytes) => self.write_text(bytes), + ParserEvent::CSI(csi) => self.handle_csi(csi), + ParserEvent::C1(c1) => self.handle_c1(c1), + ParserEvent::C0(c0) => { + if c0 == C0::NL { + newlines += 1; + } + self.handle_c0(c0); + } + } + } + newlines + } + + fn write_text(&mut self, bytes: &[u8]) { + self.buffer.with_current_span(|span, offset| { + for (cell, ch) in span.cells.iter_mut().skip(offset).zip(bytes) { + cell.character = *ch; + } + }); + + // Truncating is unlikely to happen in this scenario, but even so, + // `move_cursor_right` will clamp to `self.width` so its ok. + #[allow(clippy::cast_possible_truncation)] + self.buffer.move_cursor_right(bytes.len() as u16); + } + + fn handle_c0(&mut self, c0: C0) { + match c0 { + C0::BS => self.buffer.move_cursor_left(1), + C0::NL => { + self.buffer.move_cursor_down(1); + self.buffer.set_cursor_col(1); + self.buffer + .push_line(Line::new_empty(self.buffer.width() as usize)); + self.buffer.update_view(None); + } + C0::HT => { + self.buffer.with_current_span(|span, _| { + span.push(Cell::TAB); + }); + self.buffer.cursor.tab(); + } + C0::CR => self.buffer.set_cursor_col(1), + other => debug!("recieved unsupported C0: {:?}", other), + } + } + + #[allow(clippy::too_many_lines)] + fn handle_csi(&mut self, csi: CSI) { + match csi.kind { + CsiKind::CursorUp => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_up(int); + } + CsiKind::CursorDown | CsiKind::LineRel => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_down(int); + } + CsiKind::CursorForward | CsiKind::CharRel => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_right(int); + } + CsiKind::CursorBackward => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_left(int); + } + CsiKind::NextLine => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_down(int); + self.buffer.set_cursor_col(0); + } + CsiKind::PrecedingLine => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_up(int); + self.buffer.set_cursor_col(0); + } + CsiKind::CharAbsCHA | CsiKind::CharAbsHPA => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.set_cursor_col(int); + } + CsiKind::PositionCUP | CsiKind::PositionHVP => { + if csi.params.is_empty() { + self.buffer.set_cursor_pos((1u16, 1u16)); + return; + } + if let Some(idx) = csi.params.iter().position(|b| *b == SEMI) { + let (row, col) = ( + digits_to_int(&csi.params[0..idx]).unwrap_or(1), + digits_to_int(&csi.params[idx + 1..]).unwrap_or(1), + ); + self.buffer.set_cursor_pos((col, row)); + } else { + self.buffer.set_cursor_pos((1u16, 1u16)); + } + } + CsiKind::ForwardTab => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.tab(int); + } + CsiKind::EraseInDisplay => self + .buffer + .erase_in_display(EDKind::try_from(csi.params).unwrap_or_default()), + CsiKind::EraseInLine => self + .buffer + .erase_in_line(ELKind::try_from(csi.params).unwrap_or_default()), + CsiKind::InsertLine => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.insert_lines(int); + } + CsiKind::DeleteLine => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.delete_lines(int); + } + CsiKind::DeleteChars => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.delete_chars(int); + } + CsiKind::ScrollUp => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.scroll_up(int); + } + CsiKind::ScrollDown | CsiKind::ScrollDown1991 => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.scroll_down(int); + } + CsiKind::EraseChars => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.erase_chars(int); + } + CsiKind::BackTab => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.rtab(int); + } + CsiKind::Rep => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.with_current_line(|line, cursor| { + let last_char = { + // if last_char is intended to be ' ' then this will break + let i = line.last_filled_idx(); + line.iter() + .flatten() + .nth(i) + .expect("idx is always a cell") + .character + }; + line.iter_mut() + .flatten() + .skip(usize::from(cursor.x)) + .take(usize::from(int)) + .for_each(|c| c.character = last_char); + }); + } + CsiKind::LineAbs => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.set_cursor_row(int); + } + CsiKind::SGR => { + process_colors(csi.params, &mut self.color_state, &mut self.attrs); + self.buffer + .handle_span_colors(&self.color_state, self.attrs); + } + } + } + + fn handle_c1(&mut self, c1: C1) { + match c1 { + C1::IND | C1::DECFI => self.buffer.move_cursor_down(1), + C1::RI | C1::DECBI => self.buffer.move_cursor_up(1), + C1::NEL => { + self.buffer.move_cursor_down(1); + self.buffer.set_cursor_col(1); + } + C1::RIS => self.buffer.erase_in_display(EDKind::All), + other => debug!("recieved unsupported C1: {:?}", other), + } + } +} diff --git a/sericom-core/src/screen/process/mod.rs b/sericom-core/src/screen/process/mod.rs new file mode 100644 index 0000000..238e184 --- /dev/null +++ b/sericom-core/src/screen/process/mod.rs @@ -0,0 +1,28 @@ +mod colors; +mod driver; +mod parser; +mod xterm; + +pub use colors::{ColorState, process_colors}; +pub use driver::ScreenDriver; +pub use parser::{ByteParser, ParseState, ParserEvent}; +pub use xterm::*; + +#[must_use] +pub const fn digits_to_int(body: &[u8]) -> Option { + if body.is_empty() { + return None; + } + + let mut acc: u16 = 0; + let mut i = 0; + while i < body.len() { + let b = body[i]; + if (b < b'0') || (b > b'9') { + return None; + } + acc = acc * 10 + (b & 0x0F) as u16; + i += 1; + } + Some(acc) +} diff --git a/sericom-core/src/screen/process/parser.rs b/sericom-core/src/screen/process/parser.rs new file mode 100644 index 0000000..cf84d79 --- /dev/null +++ b/sericom-core/src/screen/process/parser.rs @@ -0,0 +1,123 @@ +use crate::screen::process::{C0, C1, CSI, CsiKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParserEvent<'a> { + Text(&'a [u8]), + CSI(CSI<'a>), + C0(C0), + C1(C1), +} + +impl std::fmt::Display for ParserEvent<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Text(items) => { + // SAFETY: Only bytes within the ascii printable text range + // are processed as Self::Text + write!(f, "Text( {} )", unsafe { str::from_utf8_unchecked(items) }) + } + Self::C0(c0) => write!(f, "C0( {c0} )"), + Self::C1(c1) => write!(f, "C1( {c1} )"), + Self::CSI(csi) => write!(f, "CSI( {csi} )"), + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub enum ParseState { + #[default] + Normal, + Esc, + Csi, + Osc, + Dcs, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ByteParser { + state: ParseState, + start_idx: Option, +} + +impl ByteParser { + #[must_use] + pub const fn new() -> Self { + Self { + state: ParseState::Normal, + start_idx: None, + } + } + + pub fn feed<'a>(&mut self, data: &'a [u8]) -> Vec> { + let mut events: Vec = Vec::new(); + for (idx, &b) in data.iter().enumerate() { + if !b.is_ascii() { + continue; + } + + match self.state { + ParseState::Normal => match b { + 0x00..=0x1F | 0x7F => { + if let Ok(ctrl) = C0::try_from(b) { + if let Some(start) = self.start_idx.take() { + events.push(ParserEvent::Text(&data[start..idx])); + } + + if ctrl == C0::ESC { + self.state = ParseState::Esc; + } else { + events.push(ParserEvent::C0(ctrl)); + } + } + } + // Regular text + _ if self.start_idx.is_none() => { + self.start_idx = Some(idx); + } + _ => {} + }, + ParseState::Esc => { + let Ok(ctrl) = C1::try_from(b) else { + self.state = ParseState::Normal; + continue; + }; + match ctrl { + C1::CSI => self.state = ParseState::Csi, + C1::DCS => self.state = ParseState::Dcs, + C1::OSC => self.state = ParseState::Osc, + _ => { + events.push(ParserEvent::C1(ctrl)); + self.state = ParseState::Normal; + } + } + } + ParseState::Csi => { + if let Ok(kind) = CsiKind::try_from(b) { + match self.start_idx.take() { + Some(start) => { + events.push(ParserEvent::CSI(CSI { + kind, + params: &data[start..idx], + })); + } + None => events.push(ParserEvent::CSI(CSI { kind, params: &[] })), + } + self.state = ParseState::Normal; + } else if self.start_idx.is_none() { + self.start_idx = Some(idx); + } + } + ParseState::Dcs => todo!(), + ParseState::Osc => todo!(), + } + } + + // Flush buffer if it is regular text + if self.state == ParseState::Normal + && let Some(start) = self.start_idx.take() + { + events.push(ParserEvent::Text(&data[start..])); + } + events + } +} diff --git a/sericom-core/src/screen/process/xterm.rs b/sericom-core/src/screen/process/xterm.rs new file mode 100644 index 0000000..53a38ab --- /dev/null +++ b/sericom-core/src/screen/process/xterm.rs @@ -0,0 +1,339 @@ +//! Resources: +//! - [xterm's docs] The source +//! - [nwm article] Nice writeup, good format for quick review +//! - [xterm.js] Nice implementation/action descriptions +//! - [ascii table] Useful hex/binary/decimal ascii resource +//! +//! [ascii table]: https://www.ascii-code.com/ +//! [xterm.js]: https://xtermjs.org/docs/api/vtfeatures/ +//! [nwm article]: https://nicholas-morris.com/articles/ansi-codes +//! [xterm's docs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html +#![allow(clippy::upper_case_acronyms)] + +/// Reset graphics mode escape sequence +pub const RESET: &[u8] = &[0x1B, b'[', b'0', b'm']; +/// Escape sequence separator ';' +pub const SEMI: u8 = b';'; + +/// Impl TryFrom for zig-like enum +macro_rules! def_xterm_tf { + ( + #[repr($as:ty)] + $(#[$attr:meta])* + pub enum $i:ident { + $( + $(#[$doc:meta])* + $variant:ident = $val:literal + ),* $(,)? + } + error = $err:literal + ) => { + #[repr($as)] + $(#[$attr])* + pub enum $i { + $( + $(#[$doc])* + $variant = $val + ),* + } + + impl TryFrom<$as> for $i { + type Error = $crate::SeriError; + + fn try_from(val: $as) -> Result { + match val { + $($val => Ok($i::$variant),)+ + _ => Err($crate::SeriError::Parsing(format!($err, val))) + } + } + } + }; +} + +def_xterm_tf! { + #[repr(u8)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum C0 { + /// Null character + NUL = 0x00, + /// Start of Heading + SOH = 0x01, + /// Start of Text + STX = 0x02, + /// End of Text + ETX = 0x03, + /// End of Transmission + EOT = 0x04, + /// Enquiry + ENQ = 0x05, + /// Acknowledge + ACK = 0x06, + /// Bell, Alert + BEL = 0x07, + /// Backspace + BS = 0x08, + /// Horizontal Tab + HT = 0x09, + /// Newline \n (Line Feed) + NL = 0x0A, + /// Vertical Tabulation + VT = 0x0B, + /// Form Feed + FF = 0x0C, + /// Carriage Return + CR = 0x0D, + /// Shift Out + SO = 0x0E, + /// Shift In + SI = 0x0F, + /// Data Link Escape + DLE = 0x10, + /// Device Control One (XON) + DC1 = 0x11, + /// Device Control Two + DC2 = 0x12, + /// Device Control Three (XOFF) + DC3 = 0x13, + /// Device Control Four + DC4 = 0x14, + /// Negative Acknowledge + NAK = 0x15, + /// Synchronous Idle + SYN = 0x16, + /// End of Transmission Block + ETB = 0x17, + /// Cancel + CAN = 0x18, + /// End of medium + EM = 0x19, + /// Substitute + SUB = 0x1A, + /// Escape + ESC = 0x1B, + /// File Separator + FS = 0x1C, + /// Group Separator + GS = 0x1D, + /// Record Separator + RS = 0x1E, + /// Unit Separator + US = 0x1F, + /// Delete + DEL = 0x7F + } + error = "Could not parse {:#X?} as C0" +} + +impl std::fmt::Display for C0 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:#X?}", *self as u8) + } +} + +def_xterm_tf! { + #[repr(u8)] + #[non_exhaustive] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + #[doc = "C1 Control Codes (ESC __)"] + pub enum C1 { + /// Index 'D' (0x84) + IND = b'D', + /// Next Line 'E' (0x85) + NEL = b'E', + /// Tab Set 'H' (0x88) + HTS = b'H', + /// Reverse Index 'M' (0x8d) + RI = b'M', + /// Device Control String 'P' (0x90) + DCS = b'P', + /// Start of Guarded Area 'V' (0x96) + SPA = b'V', + /// End of Guarded Area 'W' (0x97) + EPA = b'W', + /// Start of String 'X' (0x98) + SOS = b'X', + /// Return Terminal ID 'Z' (0x9a) + DECID = b'Z', + /// Control Sequence Intoducer '[' (0x9b) + CSI = b'[', + /// String Terminator '\' (0x9c) + ST = b'\\', + /// Operating System Command ']' (0x9d) + OSC = b']', + /// Privacy Message '^' (0x9e) + PM = b'^', + /// Application Program Command '_' (0x9f) + APC = b'_', + /// Back Index '6' (VT420+) + DECBI = b'6', + /// Save Cursor '7' (VT100) + DECSC = b'7', + /// Restore Cursor '8' (VT100) + DECRC = b'8', + /// Forward Index '9' (VT420+) + DECFI = b'9', + /// Full Reset 'c' (VT100) + RIS = b'c' + } + error = "Could not parse {:#X?} as C1" +} + +impl std::fmt::Display for C1 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", *self as u8 as char) + } +} + +def_xterm_tf! { + #[repr(u8)] + #[non_exhaustive] + #[allow(clippy::doc_markdown)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum CsiKind { + /// Cursor Up _Ps_ Times 'A' (default = 1) (CUU) + CursorUp = b'A', + /// Cursor Down _Ps_ Times 'B' (default = 1) (CUD) + CursorDown = b'B', + /// Cursor Forward _Ps_ Times 'C' (default = 1) (CUF) + CursorForward = b'C', + /// Cursor Backward _Ps_ Times 'D' (default = 1) (CUB) + CursorBackward = b'D', + /// Cursor Next Line _Ps_ Times 'E' (default = 1) (CNL) + NextLine = b'E', + /// Cursor Preceding Line _Ps_ Times 'F' (default = 1) (CPL) + PrecedingLine = b'F', + /// Cursor Character Absolute [column] 'G' (default = [row,1]) (CHA) + CharAbsCHA = b'G', + /// Cursor Position [row;column] 'H' (default = [1,1]) (CUP) + PositionCUP = b'H', + /// Cursor Forward Tabulation _Ps_ tab stops 'I' (default = 1) (CHT) + ForwardTab = b'I', + /// Erase in Display 'J' + /// + /// VT100: CSI _Ps_ J (ED) + /// VT220: CSI ? _Ps_ J (DECSED) + EraseInDisplay = b'J', + /// Erase in Line 'K' + /// + /// VT100: CSI _Ps_ K (EL) + /// VT220: CSI ? _Ps_ K (DECSEL) + EraseInLine = b'K', + /// Insert _Ps_ Line(s) 'L' (default = 1) (IL) + InsertLine = b'L', + /// Delete _Ps_ Line(s) 'M' (default = 1) (DL) + DeleteLine = b'M', + /// Delete _Ps_ Character(s) 'P' (default = 1) (DCH) + DeleteChars = b'P', + /// Scroll up _Ps_ lines 'S' (default = 1) (SU) + ScrollUp = b'S', + /// Scroll down _Ps_ lines 'T' (default = 1) (SD) + ScrollDown = b'T', + /// Erase _Ps_ Character(s) 'X' (default = 1) (ECH) + EraseChars = b'X', + /// Cursor Backward Tabulation _Ps_ tab stops 'Z' (default = 1) (CBT) + BackTab = b'Z', + /// Character Position Relative [columns] 'a' (default = [row,col+1]) (HPR) + CharRel = b'a', + /// Repeat the preceding graphic character _Ps_ times 'b' (REP) + Rep = b'b', + /// Line Position Absolute [row] 'd' (default = [1,column]) (VPA) + LineAbs = b'd', + /// Line Position Relative [rows] 'e' (default = [row+1,column]) (VPR) + LineRel = b'e', + /// Horizontal and Vertical Position [row;column] 'f' (default = [1,1]) (HVP) + PositionHVP = b'f', + /// Character Attributes 'm' (SGR) + SGR = b'm', + /// Character Position Absolute [column] '`' (default = [row,1]) (HPA) + CharAbsHPA = b'`', + /// Scroll down _Ps_ lines '^' (default = 1) (SD) + /// + /// From [xterm]: + /// > This was a publication error in the original ECMA-48 5th edition + /// > (1991) corrected in 2003. + /// + /// [xterm]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_ + ScrollDown1991 = b'^' + } + error = "Could not parse {:#X?} as CsiKind" +} + +impl std::fmt::Display for CsiKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", *self as u8 as char) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct CSI<'a> { + pub kind: CsiKind, + pub params: &'a [u8], +} + +impl std::fmt::Display for CSI<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ESC [ {} {}", + // SAFETY: It is verified that self.params is ascii when constructed + // in ByteParser via core::num::is_ascii() + unsafe { std::str::from_utf8_unchecked(self.params) }, + self.kind + ) + } +} + +def_xterm_tf! { + #[repr(u8)] + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + #[doc = "Erase in Display (ED) kinds"] + pub enum EDKind { + #[default] + Below = b'0', + Above = b'1', + All = b'2', + Saved = b'3' + } + error = "Could not parse {:#X?} as EDKind" +} + +def_xterm_tf! { + #[repr(u8)] + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + #[doc = "Erase in Line (EL) kinds"] + pub enum ELKind { + #[default] + Right = b'0', + Left = b'1', + All = b'2', + } + error = "Could not parse {:#X?} as ELKind" +} + +impl TryFrom<&[u8]> for EDKind { + type Error = crate::SeriError; + + fn try_from(value: &[u8]) -> Result { + if value.is_empty() { + Err(crate::SeriError::Parsing( + "Could not parse as EDKind".to_string(), + )) + } else { + Self::try_from(value[0]) + } + } +} + +impl TryFrom<&[u8]> for ELKind { + type Error = crate::SeriError; + + fn try_from(value: &[u8]) -> Result { + if value.is_empty() { + Err(crate::SeriError::Parsing( + "Could not parse as ELKind".to_string(), + )) + } else { + Self::try_from(value[0]) + } + } +} diff --git a/sericom-core/src/screen/rect.rs b/sericom-core/src/screen/rect.rs new file mode 100644 index 0000000..0cfb7fd --- /dev/null +++ b/sericom-core/src/screen/rect.rs @@ -0,0 +1,104 @@ +use std::cmp::{max, min}; + +use super::position::{BuffPos, PosType, PosY, Position, Scope, TermPos}; + +// #[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] +pub struct Rect { + pub(crate) width: u16, + pub(crate) height: PosY, + pub(crate) origin: Position, +} + +impl Rect { + #[must_use] + pub const fn new(origin: Position, width: u16, height: PosY) -> Self { + Self { + width, + height, + origin, + } + } + + /// The x value of [`Rect`]s left side + #[must_use] + pub const fn left(&self) -> u16 { + self.origin.x + } + + /// The x value of [`Rect`]s right side + #[must_use] + pub const fn right(&self) -> u16 { + self.origin.x.saturating_add(self.width) + } + + /// The y value of [`Rect`]s top side + #[must_use] + pub const fn top(&self) -> PosY { + self.origin.y + } +} + +impl Rect { + /// The y value of [`Rect`]s bottom side + #[must_use] + pub const fn bottom(&self) -> u16 { + self.origin.y.saturating_add(self.height) + } +} + +// These methods are only relavant to [`Rect`] +impl Rect { + /// The area (W x H) of [`Rect`] + #[must_use] + pub const fn area(&self) -> Option { + (self.width as u32).checked_mul(self.height as u32) + } + + #[must_use] + pub fn intersection(&self, other: Self) -> Self { + let x1 = max(self.left(), other.left()); + let x2 = min(self.right(), other.right()); + let y1 = max(self.top(), other.top()); + let y2 = min(self.bottom(), other.bottom()); + Self { + origin: (x1, y1).into(), + width: x2.saturating_sub(x1), + height: y2.saturating_sub(y1), + } + } +} + +impl Rect { + /// The y value of [`Rect`]s bottom side + #[must_use] + pub const fn bottom(&self) -> u32 { + self.origin.y.saturating_add(self.height) + } +} + +impl From<(P, u16, PosY)> for Rect +where + P: Into>, + S: Scope + PosType, +{ + /// Create a [`Rect`] from (origin, width, height) + fn from(value: (P, u16, PosY)) -> Self { + Self { + width: value.1, + height: value.2, + origin: value.0.into(), + } + } +} + +impl From<(u16, u16)> for Rect { + /// Creates a new `Rect` with (width, height) at [`Position::ORIGIN`] + fn from(value: (u16, u16)) -> Self { + Self { + width: value.0, + height: value.1, + origin: Position::ORIGIN, + } + } +} diff --git a/sericom-core/src/screen/render.rs b/sericom-core/src/screen/render.rs new file mode 100644 index 0000000..41604a3 --- /dev/null +++ b/sericom-core/src/screen/render.rs @@ -0,0 +1,126 @@ +#![allow(unused)] +use crossterm::style::{Attributes, Color, Colors}; +use std::io::BufWriter; +use tracing::instrument; + +use super::{ByteParser, Cell, Line, ParserEvent, Span}; +use super::{Cursor, ScreenBuffer, UIAction}; +use crate::configs::get_config; + +const MIN_RENDER_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_millis(33); + +impl ScreenBuffer { + /// Takes incoming data (bytes (`u8`) from a serial connection) and + /// processes them accordingly, handling ascii escape sequences, to + /// render as characters/strings in the terminal. + #[instrument(name = "Add Data", skip(self, data))] + pub fn add_data(&mut self, parser: &mut ByteParser, data: &[u8]) { + let events = parser.feed(data); + // self.process_events(writer, event); + } + + /// Writes the lines/characters received from `add_data` to the terminal's screen. + /// + /// As of now, `render` does not involve any diff-ing of previous renders. + /// The nature of communicating to devices over a serial connection is similar + /// that of a terminal; lines get printed to a screen and with each new line, + /// all of the previously rendered characters must be re-rendered one cell higher. + /// + /// Because of this, the only diff-ing that would make sense would be + /// that of the cells within the screen that are simply blank. + #[allow(clippy::similar_names)] + pub fn render(&mut self) -> std::io::Result<()> { + // use crossterm::{cursor, queue, style}; + // use std::io::{self, Write}; + // use tokio::time::Instant; + // + // if !self.needs_render { + // return Ok(()); + // } + // + // let mut writer = BufWriter::new(io::stdout()); + // queue!(writer, cursor::Hide)?; + // let config = get_config(); + // + // for screen_y in 0..self.height { + // let line_idx = self.view_start + screen_y as usize; + // queue!(writer, cursor::MoveTo(0, screen_y))?; + // + // if let Some(line) = self.lines.get_mut(line_idx) { + // let mut current_fg = Color::from(&config.appearance.fg); + // let mut current_bg = Color::from(&config.appearance.bg); + // queue!( + // writer, + // style::SetForegroundColor(current_fg), + // style::SetBackgroundColor(current_bg) + // )?; + // + // for cell in line { + // let global_reverse = self.display_attributes.has(style::Attribute::Reverse); + // + // let fg = if (cell.is_selected && !global_reverse) + // || (!cell.is_selected && global_reverse) + // { + // cell.bg_color + // } else { + // cell.fg_color + // }; + // + // let bg = if (cell.is_selected && !global_reverse) + // || (!cell.is_selected && global_reverse) + // { + // cell.fg_color + // } else { + // cell.bg_color + // }; + // + // if fg != current_fg { + // queue!(writer, style::SetForegroundColor(fg))?; + // current_fg = fg; + // } + // if bg != current_bg { + // queue!(writer, style::SetBackgroundColor(bg))?; + // current_bg = bg; + // } + // + // if self.display_attributes.has(style::Attribute::Bold) { + // queue!( + // writer, + // style::SetAttribute(style::Attribute::Bold), + // style::Print(cell.character) + // )?; + // } else { + // queue!(writer, style::Print(cell.character))?; + // } + // } + // } else { + // queue!( + // writer, + // style::ResetColor, + // style::Print(" ".repeat(self.width as usize)) + // )?; + // } + // } + // + // // This is relative the the terminal's L x W, whereas + // // self.cursor_pos.y is within the entire line buf + // let screen_cursor_y = if self.cursor.y >= self.view_start + // && self.cursor.y < self.view_start + self.height as usize + // { + // (self.cursor.y - self.view_start) as u16 + // } else { + // self.height - 1 + // }; + // + // queue!( + // writer, + // cursor::MoveTo(self.cursor.x, screen_cursor_y), + // cursor::Show + // )?; + // writer.flush()?; + // + // self.last_render = Some(Instant::now()); + // self.needs_render = false; + Ok(()) + } +} diff --git a/sericom-core/src/screen/tests/colors.rs b/sericom-core/src/screen/tests/colors.rs new file mode 100644 index 0000000..6fd76e2 --- /dev/null +++ b/sericom-core/src/screen/tests/colors.rs @@ -0,0 +1,342 @@ +use crate::configs::get_config; +use crate::screen::process::*; +use crossterm::style::{Attribute, Attributes, Color}; + +struct Case<'a> { + seq: &'a [u8], + expected_fg: Option, + expected_bg: Option, + expected_attrs: Vec, + label: &'a str, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn has_all(attrs: &Attributes, expected: &[Attribute]) -> bool { + expected.iter().all(|a| attrs.has(*a)) +} + +fn test_cases(cases: &Vec) { + for (idx, case) in cases.iter().enumerate() { + let mut color_state = ColorState::default(); + let mut attrs = Attributes::default(); + + process_colors(case.seq, &mut color_state, &mut attrs); + + assert_eq!( + color_state.get_colors().foreground, + case.expected_fg, + "Case# {} - Foreground mismatch on '{}'", + idx, + case.label + ); + assert_eq!( + color_state.get_colors().background, + case.expected_bg, + "Case# {} - Background mismatch on '{}'", + idx, + case.label + ); + assert!( + has_all(&attrs, &case.expected_attrs), + "Case# {} - Missing attrs on '{}': expected {:?}, got {:?}", + idx, + case.label, + case.expected_attrs, + attrs + ); + } +} + +#[test] +fn test_basic_fg() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![ + Case { + seq: b"31", + expected_fg: Some(Color::DarkRed), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG basic red", + }, + Case { + seq: b"37", + expected_fg: Some(Color::Grey), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG basic white/grey", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_basic_bg() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + drop(config); + + let cases = vec![Case { + seq: b"44", + expected_fg: Some(fg), + expected_bg: Some(Color::DarkBlue), + expected_attrs: vec![], + label: "BG basic blue", + }]; + test_cases(&cases); +} + +#[test] +fn test_bright_colors() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![ + Case { + seq: b"95", + expected_fg: Some(Color::Magenta), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG bright magenta", + }, + Case { + seq: b"106", + expected_fg: Some(fg), + expected_bg: Some(Color::Cyan), + expected_attrs: vec![], + label: "BG bright cyan", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_resets_and_defaults() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![ + Case { + seq: b"0", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Reset", + }, + Case { + seq: b"39", + expected_fg: Some(Color::Reset), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Reset FG default", + }, + Case { + seq: b"49", + expected_fg: Some(fg), + expected_bg: Some(Color::Reset), + expected_attrs: vec![], + label: "Reset BG default", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_attributes() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![Case { + seq: b"1;3;4", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined", + }]; + test_cases(&cases); +} + +#[test] +fn test_256_color_palette() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![ + Case { + seq: b"38;5;196", + expected_fg: Some(Color::AnsiValue(196)), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG 256 red", + }, + Case { + seq: b"48;5;27", + expected_fg: Some(fg), + expected_bg: Some(Color::AnsiValue(27)), + expected_attrs: vec![], + label: "BG 256 blue", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_truecolor_palette() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![ + Case { + seq: b"38;2;255;128;64", + expected_fg: Some(Color::Rgb { + r: 255, + g: 128, + b: 64, + }), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG truecolor orange", + }, + Case { + seq: b"48;2;10;20;30", + expected_fg: Some(fg), + expected_bg: Some(Color::Rgb { + r: 10, + g: 20, + b: 30, + }), + expected_attrs: vec![], + label: "BG truecolor dark", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_mix_attr_colors() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![ + Case { + seq: b"1;3;4;38;5;202", + expected_fg: Some(Color::AnsiValue(202)), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined + FG 256 orange", + }, + Case { + seq: b"5;7;48;2;128;64;200", + expected_fg: Some(fg), + expected_bg: Some(Color::Rgb { + r: 128, + g: 64, + b: 200, + }), + expected_attrs: vec![Attribute::SlowBlink, Attribute::Reverse], + label: "Blink + Reverse + BG truecolor purple", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_kitchen_sink() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![ + Case { + seq: b"1;3;38;5;202;4;48;2;10;20;30", + expected_fg: Some(Color::AnsiValue(202)), + expected_bg: Some(Color::Rgb { + r: 10, + g: 20, + b: 30, + }), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined + FG 256 + BG truecolor", + }, + Case { + seq: b"20;53", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Fraktur, Attribute::OverLined], + label: "Fraktur + Overlined", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_invalid() { + crate::configs::init_for_tests(); + let config = get_config().unwrap(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let cases = vec![ + Case { + seq: b"38;5", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Incomplete 256 FG (missing index)", + }, + Case { + seq: b"48;5;999", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Out-of-range 256 BG (999)", + }, + Case { + seq: b"38;2;255;0", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Incomplete truecolor FG (missing B)", + }, + Case { + seq: b"48;2;256;256;256", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Invalid RGB components (>255)", + }, + Case { + seq: b"999", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Unknown SGR param", + }, + ]; + test_cases(&cases); +} diff --git a/sericom-core/src/screen/tests/components.rs b/sericom-core/src/screen/tests/components.rs new file mode 100644 index 0000000..0ab0c5a --- /dev/null +++ b/sericom-core/src/screen/tests/components.rs @@ -0,0 +1,136 @@ +use std::fmt::Write; +use std::io; + +use super::*; +use crossterm::execute; +use crossterm::style::Colors; +use crossterm::style::SetAttribute; +use crossterm::style::SetColors; + +// Stole this from crossterm's test - thanks! +#[derive(Default, Debug, Clone)] +struct FakeWrite { + buffer: String, + flushed: bool, +} + +impl io::Write for FakeWrite { + fn write(&mut self, content: &[u8]) -> io::Result { + let content = + str::from_utf8(content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + self.buffer.push_str(content); + self.flushed = false; + Ok(content.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flushed = true; + Ok(()) + } +} + +#[test] +#[allow(unused)] +fn line_as_command() { + crate::configs::init_for_tests(); + let mut writer = FakeWrite { + buffer: String::new(), + flushed: false, + }; + let mut colors = ColorState::default(); + colors.set_fg(Color::Red); + let mut attrs = Attributes::default(); + attrs.set(Attribute::Bold); + + let span1: Span = "first span".chars().collect(); + let mut span2: Span = "second span".chars().collect(); + span2.set_colors(&colors); + span2.set_attrs(attrs); + let span3: Span = "third span\n".chars().collect(); + let line = Line(vec![span1, span2, span3]); + + execute!(writer, line); + + let mut cmp = String::new(); + write!( + cmp, + "{}", + SetColors(Colors::new(Color::Green, Color::Reset)) + ); + write!(cmp, "first span"); + write!(cmp, "{}", SetColors(Colors::new(Color::Red, Color::Reset))); + write!(cmp, "{}", SetAttribute(Attribute::Bold)); + write!(cmp, "second span"); + write!( + cmp, + "{}", + SetColors(Colors::new(Color::Green, Color::Reset)) + ); + writeln!(cmp, "third span"); + + assert_eq!(writer.buffer, cmp); +} + +#[test] +fn overwriting_span() { + let mut span = vec![Cell::EMPTY; 10]; + let chars = vec![b'a'; 10]; + + for (cell, ch) in span.iter_mut().skip(5).zip(chars) { + cell.character = ch; + } + + assert_eq!(span, [[Cell::EMPTY; 5], [Cell::new(b'a'); 5]].concat()); +} + +#[test] +fn span_newline() { + crate::configs::init_for_tests(); + + let mut span: Span = "my test span ".chars().collect(); + let res = span.last_filled_idx(); + assert_eq!(12, res); + { + let c = span + .cells + .get_mut(res) + .expect("span len is greater than last filled cell"); + c.character = b'\n'; + } + + let res = span.last_filled_idx(); + assert_eq!(13, res); + + span.shrink(); + + assert_eq!(span.cells.capacity(), 13); + assert_eq!(span.cells.len(), 13); +} + +#[test] +fn line_last_filled() { + crate::configs::init_for_tests(); + + let span1: Span = "first span".chars().collect(); + let span2: Span = "second span ".chars().collect(); + let line = Line(vec![span1, span2]); + + assert_eq!(line.last_filled_idx(), 20); + assert_eq!(line.iter().flatten().nth(20), Some(&Cell::new(b'n'))); +} + +#[test] +fn line_num_filled() { + crate::configs::init_for_tests(); + + let span1: Span = "first span".chars().collect(); + let span2: Span = "second span ".chars().collect(); + let line = Line(vec![span1, span2]); + + assert_eq!(line.filled_cells(), 21); + + let span: Span = " ".chars().collect(); + let line = Line(vec![span]); + + assert_eq!(line.filled_cells(), 0); +} diff --git a/sericom-core/src/screen/tests/cursor.rs b/sericom-core/src/screen/tests/cursor.rs new file mode 100644 index 0000000..f20b0e4 --- /dev/null +++ b/sericom-core/src/screen/tests/cursor.rs @@ -0,0 +1,409 @@ +use super::*; +use crate::assert_line_eq; +use crate::assert_span_eq; +use crate::csi; +use crate::setup; + +const WORDS: [&str; 4] = [ + "This is one line of words", + "This is a second line of words", + "This is a third line of words", + "This is a fourth line of words", +]; + +#[test] +fn tabs() { + setup!(sb, parser); + { + let s = format!( + "This is the first line\nThis is the second line\nThis is the third line\n{}Tabbed?", + csi!("I") + ); + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + + assert_eq!(sb.lines.len(), 4); + assert_eq!(sb.cursor, Position::::from((16u16, 4u16))); + assert_line_eq!(sb, 3, " Tabbed?"); + } + { + let s = format!( + "This is the first line\nThis is the second line\nThis is the third line\n{}Tabbed?", + csi!("3I") + ); + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + assert_eq!(sb.lines.len(), 7); + assert_eq!(sb.cursor, Position::::from((32u16, 7u16))); + assert_line_eq!(sb, 6, " Tabbed?"); + } + { + let s = format!("{}Reverse!", csi!("2Z")); + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + assert_eq!(sb.lines.len(), 7); + assert_eq!(sb.cursor, Position::::from((24u16, 7u16))); + assert_line_eq!(sb, 6, " Reverse! Tabbed?"); + } +} + +#[test] +fn cursor_nextline() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!("{}{}{}", WORDS[0], csi!("E"), WORDS[1]); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_line_eq!(sb, 0, "This is one line of words"); + assert_line_eq!(sb, 1, "This is a second line of words"); + assert_eq!(sb.cursor.x, 31); + assert_eq!(sb.cursor.y, 2); + let line = sb.curr_line().unwrap(); + assert_eq!(line.last_filled_idx(), 29); + assert_eq!(sb.lines.len(), 2); +} + +#[test] +fn cursor_up_down() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\n{}\n{}This should overwrite the first line{}NewText?\nNow another line{}Overwriting again{}Hello there!{}Above you!", + WORDS[0], + WORDS[1], + WORDS[2], + csi!("6A"), + csi!("10B"), + csi!("5A"), + csi!("2E"), + csi!("F"), + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_line_eq!(sb, 0, "This should overwrite the first line"); + assert_line_eq!(sb, 10, " NewText?"); + assert_line_eq!(sb, 11, "Now another line"); + assert_line_eq!(sb, 6, " Overwriting again"); + assert_line_eq!(sb, 8, "Hello there!"); + assert_line_eq!(sb, 7, "Above you!"); + assert_eq!(sb.cursor.y, 8); + assert_eq!(sb.lines.len(), 12); +} + +#[test] +fn clear_line_from_cursor() { + setup!(sb, parser); + let s = format!( + "This is the first line\nThis is the second line\nThis is the third line\n{}{}", + csi!("1;10H"), // move cursor to (10, 1) + csi!("K") // clear line from cursor + ); + + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + + assert_eq!(sb.lines.len(), 4); + assert_eq!(sb.cursor, Position::::from((10u16, 1u16))); + assert_line_eq!(sb, 0, "This is t"); +} + +#[test] +fn clear_from_cursor_to_top() { + setup!(sb, parser); + let s = "This is the first line\n".to_owned() + + "This is the second line\n" + + "This is the third line\n" + + "This is the fourth line\n" + + "This is the fifth line\n"; + let s = format!("{s}{}{}", csi!("3;10H"), csi!("1J")); + // move cursor to (10, 3) + // clear from cursor to beginning + + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + + assert_eq!(sb.lines.len(), 6); + assert_eq!(sb.cursor, Position::::from((10u16, 3u16))); + assert_line_eq!(sb, 0, ""); + assert_line_eq!(sb, 1, ""); + assert_line_eq!(sb, 2, " he third line"); + assert_line_eq!(sb, 3, "This is the fourth line"); + assert_line_eq!(sb, 4, "This is the fifth line"); +} + +#[test] +fn clear_and_overwrite() { + setup!(sb, parser); + let s = "This is the first line\n".to_owned() + + "This is the second line\n" + + "This is the third line\n" + + "This is the fourth line\n" + + "This is the fifth line\n"; + let s = format!("{s}{}{}\rNew words", csi!("3;10H"), csi!("1J")); + // move cursor to (10, 3) + // clear from cursor to beginning + + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + + assert_eq!(sb.lines.len(), 6); + assert_eq!(sb.cursor, Position::::from((10u16, 3u16))); + assert_line_eq!(sb, 0, ""); + assert_line_eq!(sb, 1, ""); + assert_line_eq!(sb, 2, "New wordshe third line"); + assert_line_eq!(sb, 3, "This is the fourth line"); + assert_line_eq!(sb, 4, "This is the fifth line"); +} + +#[test] +fn delete_chars_triple_span() { + use crossterm::style::Color; + + setup!(sb, parser, config); + let fg = Color::from(&config.appearance.fg); + drop(config); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}This is span 3{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("34m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("28P") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is span 3"); + assert_span_eq!(sb, 2, 0, expected => "This is", fg => fg); + assert_span_eq!(sb, 2, 1, expected => " span 3", fg => Color::DarkBlue); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); +} + +#[test] +fn erase_chars_triple_span() { + use crossterm::style::Color; + + setup!(sb, parser, config); + let fg = Color::from(&config.appearance.fg); + drop(config); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}This is span 3{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("34m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("28X") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is span 3"); + assert_span_eq!(sb, 2, 0, expected => "This is ", fg => fg); + assert_span_eq!(sb, 2, 1, expected => " ", fg => Color::DarkGreen); + assert_span_eq!(sb, 2, 2, expected => " span 3", fg => Color::DarkBlue); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); +} + +#[test] +fn delete_chars_double_span() { + use crossterm::style::Color; + + setup!(sb, parser, config); + { + let fg = Color::from(&config.appearance.fg); + drop(config); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("14P") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is span 2"); + assert_span_eq!(sb, 2, 0, expected => "This is", fg => fg); + assert_span_eq!(sb, 2, 1, expected => " span 2", fg => Color::DarkGreen); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); + } + sb.lines.clear(); + sb.cursor = crate::screen::Position::ORIGIN; + { + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("5P") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is 1This is span 2"); + assert_span_eq!(sb, 2, 0, expected => "This is 1"); + assert_span_eq!(sb, 2, 1, expected => "This is span 2"); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); + } +} + +#[test] +fn erase_chars_double_span() { + use crossterm::style::Color; + + setup!(sb, parser, config); + { + let fg = Color::from(&config.appearance.fg); + drop(config); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("14X") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is span 2"); + assert_span_eq!(sb, 2, 0, expected => "This is ", fg => fg); + assert_span_eq!(sb, 2, 1, expected => " span 2", fg => Color::DarkGreen); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); + } + sb.lines.clear(); + sb.cursor = crate::screen::Position::ORIGIN; + { + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("5X") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is 1This is span 2"); + assert_span_eq!(sb, 2, 0, expected => "This is 1"); + assert_span_eq!(sb, 2, 1, expected => "This is span 2"); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); + } +} + +#[test] +fn delete_chars_single_span() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("F"), + csi!("8C"), + csi!("6P") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 9); + assert_eq!(sb.cursor.y, 2); + assert_line_eq!(sb, 1, "This is nd line of words"); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); +} + +#[test] +fn erase_chars_single_span() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("F"), + csi!("8C"), + csi!("6X") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 9); + assert_eq!(sb.cursor.y, 2); + assert_line_eq!(sb, 1, "This is ond line of words"); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); +} + +#[test] +fn insert_delete_line() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}{}Is this line inserted?\nCool now heres another line.\nLets add another.\nNow lets delete that last one.{}{}", + WORDS[0], + WORDS[1], + csi!("L"), + csi!("A"), + csi!("M"), + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_line_eq!(sb, 1, "Is this line inserted?"); + assert_line_eq!(sb, 2, "Cool now heres another line.ds"); + assert_line_eq!(sb, 3, "Now lets delete that last one."); +} + +#[test] +fn repeat_char() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!("{}\n{}{}", WORDS[0], WORDS[1], csi!("10b"),); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_line_eq!(sb, 0, "This is one line of words"); + assert_line_eq!(sb, 1, "This is a second line of wordsssssssssss"); +} diff --git a/sericom-core/src/screen/tests/escape.rs b/sericom-core/src/screen/tests/escape.rs new file mode 100644 index 0000000..d0ceaa7 --- /dev/null +++ b/sericom-core/src/screen/tests/escape.rs @@ -0,0 +1,161 @@ +use super::*; +use crate::{assert_line_eq, assert_span_eq, csi, setup}; + +#[test] +fn single_plain_line() { + setup!(sb, parser, config); + let parsed = parser.feed(b"Hello, world!\n"); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + assert_eq!(sb.cursor, Position::::from((1_u16, 2_u16))); + // Expect one line with one span, fg=default, text padded + assert_line_eq!(sb, 0, "Hello, world!"); + assert_span_eq!(sb, 0, 0, fg => fg, bg => bg); +} + +#[test] +fn two_lines_plain_text() { + setup!(sb, parser, config); + let parsed = parser.feed(b"Hello\r\nWorld\r\n"); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + // Expected: two lines, one with "Hello" padded, one with "World" padded + assert_eq!(sb.lines.len(), 3); + assert_eq!(sb.cursor, Position::::from((1_u16, 3_u16))); + assert_line_eq!(sb, 0, "Hello"); + assert_line_eq!(sb, 1, "World"); + assert_span_eq!(sb, 0, 0, fg => fg, bg => bg); + assert_span_eq!(sb, 1, 0, fg => fg, bg => bg); +} + +#[test] +fn three_color_spans() { + setup!(sb, parser, config); + let s = format!( + "{}Red{}Green{}Blue\n", + csi!("31m"), + csi!("32m"), + csi!("34m") + ); + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let line = sb.lines.front().unwrap(); + assert_eq!(sb.lines.len(), 2); + assert_eq!(sb.cursor, Position::::from((1_u16, 2_u16))); + assert_eq!(line.len(), 3); // three spans + assert_span_eq!(sb, 0, 0, expected => "Red", fg => Color::DarkRed, bg => bg); + assert_span_eq!(sb, 0, 1, expected => "Green", fg => Color::DarkGreen, bg => bg); + assert_span_eq!(sb, 0, 2, expected => "Blue", fg => Color::DarkBlue, bg => bg); +} + +#[test] +fn no_newline_incomplete_line() { + setup!(sb, parser, config); + let parsed = parser.feed(b"Hello"); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + // Should still only contain the initial empty line + assert_eq!(sb.lines.len(), 1); + assert_eq!(sb.cursor, Position::::from((6_u16, 1_u16))); + assert_span_eq!(sb, 0, 0, expected => "Hello", fg => fg, bg => bg); +} + +#[test] +fn mixed_plain_and_color() { + setup!(sb, parser, config); + let s = format!("Normal {}Red\n", csi!("31m")); + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + // Expect two spans: "Normal " default, "Red" DarkRed + let line = sb.lines.front().unwrap(); + assert_eq!(sb.lines.len(), 2); + assert_eq!(line.len(), 2); + assert_eq!(sb.cursor, Position::::from((1_u16, 2_u16))); + assert_span_eq!(sb, 0, 0, expected => "Normal ", fg => fg, bg => bg); + assert_span_eq!(sb, 0, 1, expected => "Red", fg => Color::DarkRed, bg => bg); +} + +#[test] +fn bold_italic_span() { + setup!(sb, parser); + + let parsed = parser.feed(b"\x1b[1;3mHello\n"); // bold + italic + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + + let span_attrs = Attributes::from(Attribute::Bold) | Attributes::from(Attribute::Italic); + assert_span_eq!(sb, 0, 0, attrs => span_attrs); +} + +#[test_log::test] +#[allow(clippy::cognitive_complexity)] +fn multiline_multicolor() { + setup!(sb, parser, config); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + drop(config); + + let input = concat!( + // Line 1: basic color DarkRed -> text + "\x1b[31mRed ", + // switch to Cyan + "\x1b[96mCyan ", + // switch to bold, underline, DarkBlue, then some text + "\x1b[1;4;34mBoldUnderBlue\n", + // Line 2: 256-color palette FG (202 = orange) and BG (27 = blueish) + "\x1b[38;5;202;48;5;27mOrangeOnBlue ", + // reset, then normal text + "\x1b[0mResetHere\n", + // Line 3: truecolor foreground text + "\x1b[38;2;128;200;64mTrueColorGreenish ", + // add truecolor background text with italic attr + "\x1b[3;48;2;200;64;128mBgPinkItalic\n", + ) + .as_bytes(); + + let parsed = parser.feed(input); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + + // Buffer should have initial empty + 3 lines + assert_eq!(sb.lines.len(), 4); + + // Line 1 + assert_line_eq!(sb, 0, "Red Cyan BoldUnderBlue"); + assert_span_eq!(sb, 0, 0, expected => "Red", fg => Color::DarkRed, bg => bg); + assert_span_eq!(sb, 0, 1, expected => "Cyan", fg => Color::Cyan, bg => bg); + let span_attrs = Attributes::from(Attribute::Bold) | Attributes::from(Attribute::Underlined); + assert_span_eq!(sb, 0, 2, expected => "BoldUnderBlue", fg => Color::DarkBlue, bg => bg, attrs => span_attrs); + + // Line 2 + assert_line_eq!(sb, 1, "OrangeOnBlue ResetHere"); + assert_span_eq!(sb, 1, 0, expected => "OrangeOnBlue", fg => Color::AnsiValue(202), bg => Color::AnsiValue(27)); + assert_span_eq!(sb, 1, 1, expected => "ResetHere", fg => fg, bg => bg, attrs => Attributes::default()); + + // Line 3 + assert_line_eq!(sb, 2, "TrueColorGreenish BgPinkItalic"); + assert_span_eq!(sb, 2, 0, expected => "TrueColorGreenish", fg => Color::Rgb { r:128, g:200, b:64 }, bg => bg); + let italic = Attributes::from(Attribute::Italic); + assert_span_eq!(sb, 2, 1, expected => "BgPinkItalic", fg => Color::Rgb { r:128, g:200, b:64 }, bg => Color::Rgb { r:200, g:64, b:128 }, attrs => italic); +} diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs new file mode 100644 index 0000000..51a0c27 --- /dev/null +++ b/sericom-core/src/screen/tests/mod.rs @@ -0,0 +1,190 @@ +mod colors; +mod components; +mod cursor; +mod escape; +mod parsing; + +use crate::screen::process::*; +pub use crate::screen::{ScreenDriver, *}; +pub use crossterm::style::{Attribute, Attributes, Color}; +pub use std::collections::VecDeque; + +#[macro_export] +macro_rules! csi { + ($seq:literal) => { + concat!("\x1b\x5b", $seq) + }; +} + +#[macro_export] +macro_rules! esc { + ($seq:literal) => { + concat!("\x1b", $seq) + }; +} + +#[macro_export] +macro_rules! assert_event { + ($lhs:expr, $text:literal) => { + assert_eq!($lhs, ParserEvent::Text($text)) + }; + ($lhs:expr, c0 = $c0:path) => { + assert_eq!($lhs, ParserEvent::C0($c0)) + }; + ($lhs:expr, c1 = $c1:path) => { + assert_eq!($lhs, ParserEvent::C1($c1)) + }; + ($lhs:expr, $kind:path, $params:expr) => { + assert_eq!( + $lhs, + ParserEvent::CSI(CSI { + kind: $kind, + params: $params + }) + ) + }; +} + +#[macro_export] +macro_rules! setup { + ($sb:ident, $parser:ident) => { + $crate::configs::init_for_tests(); + let mut $sb = ScreenBuffer::new((80u16, 24u16).into()); + let mut $parser = ByteParser::new(); + }; + ($sb:ident, $parser:ident, $config:ident) => { + $crate::configs::init_for_tests(); + let mut $sb = ScreenBuffer::new((80u16, 24u16).into()); + let mut $parser = ByteParser::new(); + let $config = $crate::configs::get_config().unwrap(); + }; +} + +/// Assert that a line's text contents equal `expected` +/// Usage: +/// `assert_line_eq!(sb, 1, "Hello, world!");` +#[macro_export] +macro_rules! assert_line_eq { + ($sb:expr, $line_idx:expr, $expected:expr) => {{ + let sb = &$sb; // ScreenBuffer + let line_idx = $line_idx; // which line + let expected: &str = $expected; // expected text + + let line = sb.lines.get(line_idx).expect(&format!( + "No line at index {line_idx}, only {} lines", + sb.lines.len() + )); + let actual: String = line.iter().flatten().collect(); + + if actual.trim_end() != expected { + panic!( + "Line {line_idx} mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", + expected, + actual, + $crate::screen::tests::debug_dump(&sb.lines), + ); + } + }}; +} + +/// Assert that a specific span on a line matches given text and style. +/// Usage: +/// `assert_span_eq!(sb, 1, 0, "Red", fg=Color::DarkRed, bg=Color::Reset);` +#[macro_export] +macro_rules! assert_span_eq { + ( + $sb:expr, // ScreenBuffer + $line_idx:expr, // which line + $span_idx:expr // which span in that line + $(, expected => $expected_text:expr)? + $(, fg => $fg_color:expr)? + $(, bg => $bg_color:expr)? + $(, attrs => $attrs:expr)? + ) => {{ + let sb = &$sb; + let line_idx = $line_idx; + let span_idx = $span_idx; + + let line = sb + .lines + .get(line_idx) + .expect(&format!("No line at index {line_idx}, only {} lines", sb.lines.len())); + let span = line + .iter() + .nth(span_idx) + .expect(&format!("No span at index {span_idx} in line {}", line_idx)); + + // Collect text from cells + $( + let expected_text: &str = $expected_text; + let actual_text: String = span.iter().collect(); + if !actual_text.starts_with(expected_text) { + panic!( + "Span {span_idx} text mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", + expected_text, + actual_text, + $crate::screen::tests::debug_dump(&sb.lines), + ); + } + )? + + // Check optional style args + $( + assert_eq!( + span.colors.foreground, + Some($fg_color), + "Span {} fg color mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $fg_color, + span.colors.foreground, + $crate::screen::tests::debug_dump(&sb.lines), + ); + )? + $( + assert_eq!( + span.colors.background, + Some($bg_color), + "Span {} bg color mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $bg_color, + span.colors.background, + $crate::screen::tests::debug_dump(&sb.lines), + ); + )? + $( + assert_eq!( + span.attrs, + $attrs, + "Span {} attrs mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $attrs, + span.attrs, + $crate::screen::tests::debug_dump(&sb.lines), + ); + )? + }}; +} + +pub fn debug_dump(lines: &VecDeque) -> String { + let mut out = String::new(); + for (i, line) in lines.iter().enumerate() { + use std::fmt::Write; + + writeln!(&mut out, "Line {i}:").unwrap(); + + for (j, span) in line.iter().enumerate() { + let text: String = span.iter().map(|c| c.character as char).collect(); + writeln!( + &mut out, + " Span {}: \"{}\" (len = {}, attrs = {:?}, colors = {:?})", + j, + text, + span.len(), + span.attrs, + span.colors + ) + .unwrap(); + } + } + out +} diff --git a/sericom-core/src/screen/tests/parsing.rs b/sericom-core/src/screen/tests/parsing.rs new file mode 100644 index 0000000..658de8a --- /dev/null +++ b/sericom-core/src/screen/tests/parsing.rs @@ -0,0 +1,31 @@ +use super::*; +use crate::assert_event; + +#[test] +fn parser_basic() { + let mut parser = ByteParser::new(); + let bytes = b"\x1b[HI should be home now at 1,1\n\ + This is now the second linr, oops lets change that\x1b[24Gline\x1b[E\ + This should now be the third line. Lets do the next in blue and bold.\n\ + \x1b[1;34mAm I blue now??\x1b[0m\ + "; + let parsed = parser.feed(bytes); + assert_event!(parsed[0], CsiKind::PositionCUP, &[]); + assert_event!(parsed[1], b"I should be home now at 1,1"); + assert_event!(parsed[2], c0 = C0::NL); + assert_event!( + parsed[3], + b"This is now the second linr, oops lets change that" + ); + assert_event!(parsed[4], CsiKind::CharAbsCHA, b"24"); + assert_event!(parsed[5], b"line"); + assert_event!(parsed[6], CsiKind::NextLine, &[]); + assert_event!( + parsed[7], + b"This should now be the third line. Lets do the next in blue and bold." + ); + assert_event!(parsed[8], c0 = C0::NL); + assert_event!(parsed[9], CsiKind::SGR, b"1;34"); + assert_event!(parsed[10], b"Am I blue now??"); + assert_event!(parsed[11], CsiKind::SGR, b"0"); +} diff --git a/sericom-core/src/screen/ui_command.rs b/sericom-core/src/screen/ui_command.rs new file mode 100644 index 0000000..59b7c55 --- /dev/null +++ b/sericom-core/src/screen/ui_command.rs @@ -0,0 +1,219 @@ +use super::position::{Cursor, Position, TermPos}; +use super::{Line, ScreenBuffer}; + +/// `UICommand` is used for communication between stdin and the [`ScreenBuffer`]. +#[non_exhaustive] +#[derive(Clone, Debug)] +pub enum UICommand { + /// Scrolls up by `usize` lines + ScrollUp(u32), + /// Scrolls down by `usize` lines + ScrollDown(u32), + /// Scrolls to the last line (most recent) + ScrollBottom, + /// Scrolls to the beginning of the scrollback buffer (oldest line) + ScrollTop, + /// Starts text-selection at [`Position`] + StartSelection(Position), + /// Updates text-selection to [`Position`] + UpdateSelection(Position), + /// Copies the underlying selected text to the user's clipboard + CopySelection, + /// Completely clears the lines in the scrollback buffer + ClearBuffer, +} + +pub trait UIAction { + fn scroll_up(&mut self, lines: u32); + fn scroll_down(&mut self, lines: u32); + fn scroll_to_bottom(&mut self); + fn scroll_to_top(&mut self); + fn start_selection(&mut self, pos: Position); + fn update_selection(&mut self, pos: Position); + fn clear_selection(&mut self); + fn copy_to_clipboard(&mut self) -> std::io::Result<()>; + fn clear_buffer(&mut self); + fn clear_screen(&mut self); +} + +impl UIAction for ScreenBuffer { + /// Called to scroll the terminal up by `lines`. + fn scroll_up(&mut self, lines: u32) { + if self.view_start >= lines { + self.view_start -= lines; + } else { + self.view_start = 0; + } + self.clear_selection(); + // self.needs_render = true; + } + + /// Called to scroll the terminal down by `lines`. + fn scroll_down(&mut self, lines: u32) { + // Cast is fine cause MAX_SCROLLBACK is u32 so lines can't be > u32::MAX + let max_view_start: u32 = (self.lines.len() as u32) + .saturating_sub(self.rect.height.into()) + .into(); + self.view_start = (self.view_start + lines).min(max_view_start); + self.clear_selection(); + // self.needs_render = true; + } + + /// Scrolls to the bottom of the screen. The bottom of the screen is + /// the same as the most recent lines received from the serial connection + fn scroll_to_bottom(&mut self) { + // Cast is fine cause MAX_SCROLLBACK is u32 so lines can't be > u32::MAX + self.view_start = (self.lines.len() as u32).saturating_sub(self.rect.height.into()); + // self.needs_render = true; + } + + /// Scrolls to the top of the serial connection's history. + fn scroll_to_top(&mut self) { + self.view_start = 0; + // self.needs_render = true; + } + + /// Sets the position within the screen for the start of a selection. + /// Where `screen_x` is the x-position of the start of the selection, + /// and `screen_y` is the y-position (line) of the start of the selection. + fn start_selection(&mut self, pos: Position) { + use super::TranslatePos; + self.to_buff(&pos); + self.clear_selection(); + // self.selection_start = Some((pos.x, absolute_line)); + // self.needs_render = true; + } + + /// Update's a selection to include the position passed to it. + /// Where `screen_x` is the x-position and `screen_y` is the y-position (line). + fn update_selection(&mut self, pos: Position) { + use super::TranslatePos; + self.to_buff(&pos); + // self.selection_end = Some((pos.x, absolute_line)); + self.update_selection_highlighting(); + // self.needs_render = true; + } + + /// Clears the selection state. + fn clear_selection(&mut self) { + for line in &mut self.lines { + line.clear_selection(); + } + // self.selection_start = None; + // self.selection_end = None; + // self.needs_render = true; + } + + /// Copy's the currently selected text to the user's clipboard. + fn copy_to_clipboard(&mut self) -> std::io::Result<()> { + use crossterm::{clipboard, execute}; + + let selected_text = self.get_selected_text(); + if !selected_text.is_empty() { + execute!( + std::io::stdout(), + clipboard::CopyToClipboard::to_clipboard_from(selected_text) + )?; + } + self.clear_selection(); + Ok(()) + } + + /// Clears the entire serial connection's history and reset's the screen. + /// Similar to Ctrl + Shift + l in a terminal, + /// except this will reset the connection's message history (on the user's side). + fn clear_buffer(&mut self) { + self.lines.clear(); + self.view_start = 0; + self.set_cursor_pos((0_u16, 0_usize)); + self.lines.push_back(Line::new_empty(self.width().into())); + // self.needs_render = true; + } + + /// Clears the current *visible* screen while keeping the buffer's history + fn clear_screen(&mut self) { + for _ in 0..self.rect.height { + self.lines.push_back(Line::new_empty(self.width().into())); + } + self.view_start = (self.lines.len() as u32).saturating_sub(self.rect.height.into()); + // self.needs_render = true; + } +} + +impl ScreenBuffer { + fn update_selection_highlighting(&mut self) { + // for line in &mut self.lines { + // line.clear_selection(); + // } + // + // if let (Some((start_x, start_line)), Some((end_x, end_line))) = + // // (self.selection_start, self.selection_end) + // { + // let (start_line, start_x, end_line, end_x) = + // // If start < end or if start = end, start x has to be less than end x + // if start_line < end_line || (start_line == end_line && start_x <= end_x) { + // (start_line, start_x, end_line, end_x) + // } else { + // (end_line, end_x, start_line, start_x) + // }; + // + // for line_idx in start_line..=end_line { + // if let Some(line) = self.lines.get_mut(line_idx) { + // let line_start_x = if line_idx == start_line { start_x } else { 0 }; + // let line_end_x = if line_idx == end_line { + // end_x + // } else { + // self.rect.width - 1 + // }; + // + // // for x in line_start_x..=line_end_x.min(self.width - 1) { + // // if let Some(cell) = line.get_mut_cell(x as usize) { + // // cell.is_selected = true; + // // } + // // } + // } + // } + // } + } + + fn get_selected_text(&self) -> String { + todo!() + // if let (Some((start_x, start_line)), Some((end_x, end_line))) = + // (self.selection_start, self.selection_end) + // { + // let (start_line, start_x, end_line, end_x) = + // if start_line < end_line || (start_line == end_line && start_x <= end_x) { + // (start_line, start_x, end_line, end_x) + // } else { + // (end_line, end_x, start_line, start_x) + // }; + // + // let mut result = String::new(); + // + // for line_idx in start_line..=end_line { + // if let Some(line) = self.lines.get(line_idx) { + // let line_start_x = if line_idx == start_line { start_x } else { 0 }; + // let line_end_x = if line_idx == end_line { + // end_x + // } else { + // self.rect.width - 1 + // }; + // + // // for x in line_start_x..=line_end_x.min(self.width - 1) { + // // if let Some(cell) = line.get_cell(x as usize) { + // // result.push(cell.character); + // // } + // // } + // + // if line_idx < end_line { + // result.push('\n'); + // } + // } + // } + // + // result.trim_end().to_string() + // } else { + // String::new() + // } + } +} diff --git a/sericom-core/src/screen_buffer/cell.rs b/sericom-core/src/screen_buffer/cell.rs deleted file mode 100644 index 9dba7fb..0000000 --- a/sericom-core/src/screen_buffer/cell.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crossterm::style::Color; - -use crate::configs::get_config; - -/// `Cell` represents a cell within the terminal's window/frame. -/// -/// Used to hold rendering state for all the cells within the [`ScreenBuffer`][`super::ScreenBuffer`]. -/// Each line within [`ScreenBuffer`][`super::ScreenBuffer`] is represented by a `Vec`. -#[derive(Clone, Debug)] -pub struct Cell { - pub(super) character: char, - pub(super) fg_color: Color, - pub(super) bg_color: Color, - pub(super) is_selected: bool, -} - -impl Default for Cell { - /// The default for [`Cell`] is the fg color from [`Appearance.fg`][`crate::configs::Appearance`], - /// the bg color from [`Appearance.bg`][`crate::configs::Appearance`], `' '` for the character, and is not selected. - fn default() -> Self { - let config = get_config(); - Self { - character: ' ', - fg_color: Color::from(&config.appearance.fg), - bg_color: Color::from(&config.appearance.bg), - is_selected: false, - } - } -} diff --git a/sericom-core/src/screen_buffer/cursor.rs b/sericom-core/src/screen_buffer/cursor.rs deleted file mode 100644 index b27d95b..0000000 --- a/sericom-core/src/screen_buffer/cursor.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::fmt::Display; - -use super::{Line, ScreenBuffer}; - -/// Represent's the cursor's position within the [`ScreenBuffer`]. -#[derive(Clone, Copy, Debug)] -pub struct Position { - /// The column within [`ScreenBuffer`]'s scrollback buffer. - /// This translates to the [`Cell`][`super::Cell`] within a line (`Vec`). - pub(crate) x: u16, - /// The line number within [`ScreenBuffer`]'s scrollback buffer. - pub(crate) y: usize, -} - -impl Position { - /// Sets [`Position`] to (0,0) - pub(super) const fn home() -> Self { - Self { x: 0, y: 0 } - } -} - -impl From<(u16, usize)> for Position { - fn from((x, y): (u16, usize)) -> Self { - Self { x, y } - } -} - -impl From<(u16, u16)> for Position { - fn from((x, y): (u16, u16)) -> Self { - Self { x, y: y as usize } - } -} - -impl From for (u16, usize) { - fn from(position: Position) -> Self { - (position.x, position.y) - } -} - -impl From for (u16, u16) { - fn from(position: Position) -> Self { - (position.x, position.y as u16) - } -} - -impl Display for Position { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "({}, {})", self.x, self.y) - } -} - -pub trait Cursor { - fn set_cursor_pos>(&mut self, position: P); - fn move_cursor_left(&mut self, cells: u16); - fn move_cursor_up(&mut self, lines: u16); - fn move_cursor_down(&mut self, lines: u16); - fn move_cursor_right(&mut self, cells: u16); - fn set_cursor_col(&mut self, col: u16); -} - -impl Cursor for ScreenBuffer { - /// Sets the cursor position. - fn set_cursor_pos>(&mut self, position: P) { - self.cursor_pos = position.into(); - } - - /// Moves the cursor left by `cells`. - fn move_cursor_left(&mut self, cells: u16) { - self.cursor_pos.x = self.cursor_pos.x.saturating_sub(cells); - } - - /// Moves the cursor up by `lines`. - fn move_cursor_up(&mut self, lines: u16) { - self.cursor_pos.y = self.cursor_pos.y.saturating_sub(lines as usize); - } - - /// Moves the cursor down by `lines`. - fn move_cursor_down(&mut self, lines: u16) { - self.cursor_pos.y = self.cursor_pos.y.saturating_add(lines as usize); - while self.cursor_pos.y > self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); - } - } - - /// Moves the cursor right by `cells`. - fn move_cursor_right(&mut self, cells: u16) { - self.cursor_pos.x = self.cursor_pos.x.saturating_add(cells); - } - - /// Sets the column of the cursor - fn set_cursor_col(&mut self, col: u16) { - self.cursor_pos.x = col; - } -} diff --git a/sericom-core/src/screen_buffer/escape.rs b/sericom-core/src/screen_buffer/escape.rs deleted file mode 100644 index 2afe2c5..0000000 --- a/sericom-core/src/screen_buffer/escape.rs +++ /dev/null @@ -1,240 +0,0 @@ -use crossterm::style::Attributes; -use tracing::debug; - -use super::{Cursor, Line, ScreenBuffer}; -use crate::screen_buffer::UIAction; - -/// `EscapeState` holds stateful information about the incoming -/// data to allow for proper processing of ansii escape codes/characters. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum EscapeState { - /// Has not received ansii escape characters - Normal, - /// Just received an ESC (0x1B) - Esc, - /// Received ESC and then '[' (0x5B) - Csi, -} - -/// Represents a section of an ascii escape sequence. -#[derive(Clone, Default, Debug, PartialEq, Eq)] -pub(super) enum EscapePart { - /// The default state when not actively processing an escape sequence. - #[default] - Empty, - /// Collects ascii digits (0-9) as they are received individually and are - /// eventually combined to create the final number that the `Action` will - /// perform on i.e. `vec!['2', '3']` -> `23`. - Numbers(Vec), - /// The `Separator` represents the `;` used in ascii escape sequences. - Separator, - /// The `Action` represents the (typically) last letter of an escape - /// sequence that determines what action is to be taken i.e. `ESC[2J`. - Action(char), -} - -/// A state-holder/collection for building ascii escape sequences -/// from incoming data to enable proper processing/execution. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct EscapeSequence { - sequence: Vec, - part: EscapePart, -} - -impl EscapeSequence { - pub(super) fn new() -> Self { - Self { - sequence: Vec::new(), - part: EscapePart::Empty, - } - } - - /// Clear's the sequence and sets [`Self::part`] to [`EscapePart::Empty`]. - pub(super) fn reset(&mut self) { - // Clear is probably good since it will continue to - // fill up to similar sizes throughout the program. - self.sequence.clear(); - self.part = EscapePart::Empty; - } - - /// Appends the [`Self::part`] that is currently being processed to [`Self::sequence`]. - fn push_part(&mut self) { - self.sequence.push(std::mem::take(&mut self.part)); - } - - /// Appends a [`EscapePart::Separator`] to [`Self::sequence`]. - pub(super) fn insert_separator(&mut self) { - if self.part != EscapePart::Empty { - self.push_part(); - } - self.sequence.push(EscapePart::Separator); - } - - /// Adds numbers to the in-progress part of the escape sequence - /// - /// Passes `num` as a char because `num` must be `.is_ascii_digit()` - /// [`ScreenBuffer::parse_sequence()`] builds the ascii escape sequence - /// line/column number from pushing `char`s to a string and parsing the - /// `String` to `u16`. - pub(super) fn push_num(&mut self, num: char) { - match &mut self.part { - EscapePart::Numbers(nums) => nums.push(num), - _ => self.part = EscapePart::Numbers(vec![num]), - } - } - - /// Pushes the action to the escape sequence, signaling the end - /// and results in carrying out the action for the escape sequence - /// and then resetting its values. - pub(super) fn push_action(&mut self, action: char) { - if self.part != EscapePart::Empty { - self.push_part(); - } - self.sequence.push(EscapePart::Action(action)); - } -} - -impl ScreenBuffer { - /// Parse the built [`EscapeSequence`] and runs the respective action. - pub(crate) fn parse_sequence(&mut self) { - let span = tracing::span!(tracing::Level::DEBUG, "Escape sequence"); - let _enter = span.enter(); - match &self.escape_sequence.sequence[..] { - [ - EscapePart::Numbers(x), - EscapePart::Separator, - EscapePart::Numbers(y), - EscapePart::Separator, - EscapePart::Numbers(z), - EscapePart::Action('m'), - ] => { - debug!("Got: 'ESC[{:?};{:?};{:?}m'", x, y, z); - let x: u16 = x.iter().collect::().parse().unwrap(); - let y: u16 = y.iter().collect::().parse().unwrap(); - let z: u16 = z.iter().collect::().parse().unwrap(); - if x == 0 { - self.display_attributes = Attributes::none(); - } - if y == 1 { - self.display_attributes - .set(crossterm::style::Attribute::Bold); - } - if z == 7 { - self.display_attributes - .set(crossterm::style::Attribute::Reverse); - } - self.escape_state = EscapeState::Normal; - } - [ - EscapePart::Numbers(line_nums), - EscapePart::Separator, - EscapePart::Numbers(col_nums), - EscapePart::Action(action), - ] => { - debug!("Got: 'ESC[{:?};{:?}{}'", line_nums, col_nums, action); - match action { - // Move cursor to (line_num, col_num) - 'H' | 'f' => { - // Can unwrap because it is guaranteed elsewhere that - // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). - let mut line_num: u16 = - line_nums.iter().collect::().parse().unwrap(); - let col_num: u16 = col_nums.iter().collect::().parse().unwrap(); - if line_num <= 1 { - line_num = (self.lines.len() as u16).saturating_sub(self.height); - } else { - line_num += (self.lines.len() as u16).saturating_sub(self.height); - } - self.set_cursor_pos((col_num.saturating_sub(1), line_num)); - } - _ => {} - } - self.escape_state = EscapeState::Normal; - } - [ - EscapePart::Separator, - EscapePart::Numbers(col_nums), - EscapePart::Action(action), - ] => { - debug!("Got: 'ESC[;{:?}{}'", col_nums, action); - match action { - // Move cursor to (same, col_num) - 'H' | 'f' => { - // Can unwrap because it is guaranteed elsewhere that - // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). - let col_num: u16 = col_nums.iter().collect::().parse().unwrap(); - self.cursor_pos.x = col_num.saturating_sub(1); - } - _ => {} - } - self.escape_state = EscapeState::Normal; - } - [EscapePart::Numbers(nums), EscapePart::Action(action)] => { - debug!("Got: 'ESC[{:?}{}'", nums, action); - // Can unwrap because it is guaranteed elsewhere that - // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). - let num: u16 = nums.iter().collect::().parse().unwrap(); - match (num, action) { - // Move cursor up # of lines - (num, 'A') => self.move_cursor_up(num), - // Move cursor down # of lines - (num, 'B') => self.move_cursor_down(num), - // Move cursor right # of cols - (num, 'C') => self.move_cursor_right(num), - // Move cursor left # of cols - (num, 'D') => self.move_cursor_left(num), - // Moves cursor to beginning of line, # lines down - (num, 'E') => { - self.set_cursor_pos((0, (self.cursor_pos.y as u16) + num)); - while self.cursor_pos.y > self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); - } - } - // Moves cursor to beginning of line, # lines up - (num, 'F') => self.set_cursor_pos((0, (self.cursor_pos.y as u16) - num)), - // Moves cursor to column # - (num, 'G') => self.set_cursor_col(num), - // Erase from cursor until end of screen - (0, 'J') => self.clear_from_cursor_to_eos(), - // Erase from cursor to beginning of screen - (1, 'J') => self.clear_from_cursor_to_sos(), - // Erase entire screen - (2, 'J') => self.clear_screen(), - // Erase from cursor to end of line - (0, 'K') => self.clear_from_cursor_to_eol(), - // Erase start of line to cursor - (1, 'K') => self.clear_from_cursor_to_sol(), - // Erase entire line - (2, 'K') => self.clear_whole_line(), - _ => {} - } - self.escape_state = EscapeState::Normal; - } - [EscapePart::Action(action)] => { - debug!("Got: 'ESC[{}'", action); - match action { - // Set cursor position to 0, 0 of screen - 'H' => { - self.set_cursor_pos((0, self.lines.len().saturating_sub(self.view_start))); - } - // Erase from cursor until end of screen - 'J' => self.clear_from_cursor_to_eos(), - // Erase from cursor to end of line - 'K' => self.clear_from_cursor_to_eol(), - 'C' => self.move_cursor_right(1), - 'D' => self.move_cursor_left(1), - 'm' => { - self.display_attributes = Attributes::none(); - } - action if action.is_alphabetic() => {} - _ => {} - } - self.escape_state = EscapeState::Normal; - } - other => { - debug!("Unhandled ESC: 'ESC[{:?}'", other); - self.escape_state = EscapeState::Normal; - } - } - } -} diff --git a/sericom-core/src/screen_buffer/line.rs b/sericom-core/src/screen_buffer/line.rs deleted file mode 100644 index b59b6d6..0000000 --- a/sericom-core/src/screen_buffer/line.rs +++ /dev/null @@ -1,103 +0,0 @@ -use super::Cell; -use std::ops::{Index, IndexMut}; - -/// Line is a wrapper around [`Vec`] and represents a line within the [`ScreenBuffer`][`super::ScreenBuffer`]. -#[derive(Clone, Debug)] -pub struct Line(Vec); - -impl Line { - /// Create a new line with the length/size of `width`. - /// - /// Filled with [`Cell::default()`]. - pub fn new(width: usize) -> Self { - Self(vec![Cell::default(); width]) - } - - /// Iterates over all the [`Cell`]s within the line and sets them to [`Cell::default()`]. - pub fn reset(&mut self) { - self.0.iter_mut().for_each(|cell| *cell = Cell::default()); - } - - /// Iterates over the [`Cell`]s to index `idx` within [`Self`] - /// and sets them to [`Cell::default()`]. - pub fn reset_to(&mut self, idx: usize) { - self.0[..idx] - .iter_mut() - .for_each(|cell| *cell = Cell::default()); - } - - /// Iterates over the [`Cell`]s from index `idx` within [`Self`] - /// to the end of [`Self`] and sets them to [`Cell::default()`]. - pub fn reset_from(&mut self, idx: usize) { - self.0 - .iter_mut() - .skip(idx) - .for_each(|cell| *cell = Cell::default()); - } - - /// Sets the character in [`Cell`] at [`Self`]\[`idx`\] to `ch`. - pub fn set_char(&mut self, idx: usize, ch: char) { - self.0[idx].character = ch; - } - - /// Util function to return the length of [`Self`]. - #[allow(clippy::len_without_is_empty)] - pub const fn len(&self) -> usize { - self.0.len() - } - - /// Iterates over the [`Cell`]s and resets their selected state. - pub fn clear_selection(&mut self) { - self.0.iter_mut().for_each(|cell| cell.is_selected = false); - } - - /// Returns a reference to [`Cell`] at `idx`. - pub fn get_cell(&self, idx: usize) -> Option<&Cell> { - self.0.get(idx) - } - - /// Returns a mutable reference to [`Cell`] at `idx`. - pub fn get_mut_cell(&mut self, idx: usize) -> Option<&mut Cell> { - self.0.get_mut(idx) - } -} - -impl IntoIterator for Line { - type Item = Cell; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } -} - -impl<'a> IntoIterator for &'a Line { - type Item = &'a Cell; - type IntoIter = std::slice::Iter<'a, Cell>; - - fn into_iter(self) -> Self::IntoIter { - self.0.iter() - } -} - -impl<'a> IntoIterator for &'a mut Line { - type Item = &'a mut Cell; - type IntoIter = std::slice::IterMut<'a, Cell>; - - fn into_iter(self) -> Self::IntoIter { - self.0.iter_mut() - } -} - -impl Index for Line { - type Output = Cell; - fn index(&self, index: usize) -> &Self::Output { - &self.0[index] - } -} - -impl IndexMut for Line { - fn index_mut(&mut self, index: usize) -> &mut Self::Output { - &mut self.0[index] - } -} diff --git a/sericom-core/src/screen_buffer/mod.rs b/sericom-core/src/screen_buffer/mod.rs deleted file mode 100644 index 92d12ab..0000000 --- a/sericom-core/src/screen_buffer/mod.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! This module contains the code needed for the implementation of a -//! stateful buffer that holds a history of the lines/data received -//! from the serial connection and the rendering/updating of the buffer -//! to the terminal screen (stdout). -//! -//! Simply writing the data received from the serial connection directly -//! to stdout creates one main issue: there is no history of previous lines -//! that were received from the serial connection. Without a screen buffer, -//! lines would simply be wiped from existence as they exit the terminal's screen. -//! -//! As a result, there would be no way to implement features like scrolling, -//! highlighting text (for UI purposes), and getting characters at specific -//! locations within the screen for things like copying to a clipboard. -//! -//! The screen buffer solves these issues by storing each line received from the -//! connection in a [`VecDeque`]. It is important to note that -//! currently, the **capacity of the [`VecDeque`] is hardcoded with a value of 10,000 -//! lines with [`MAX_SCROLLBACK`]**. - -mod cell; -mod cursor; -mod escape; -mod line; -mod render; -mod ui_command; -pub use cell::*; -use crossterm::style::Attributes; -pub use cursor::*; -use escape::{EscapeSequence, EscapeState}; -pub use line::*; -pub use ui_command::*; - -use std::collections::VecDeque; - -/// The maximum number of lines stored in memory in [`ScreenBuffer`]. -pub const MAX_SCROLLBACK: usize = 10000; - -/// The `ScreenBuffer` holds rendering state for the entire terminal's window/frame. -/// -/// It mainly serves to allow for user-interactions that require a history and location -/// of the data displayed within the terminal i.e. copy/paste, scrolling, & highlighting. -#[derive(Debug)] -pub struct ScreenBuffer { - /// Terminal width - width: u16, - /// Terminal height - height: u16, - /// Scrollback buffer (all lines received from the serial connection). - /// Limited by memory. - lines: VecDeque, - /// Current view into the buffer. - /// Denotes which line is at the top of the screen. - view_start: usize, - /// Position of the cursor within the `ScreenBuffer`. - cursor_pos: Position, - /// Start of text selection. Used for highlighting and copying to clipboard. - selection_start: Option<(u16, usize)>, - /// End of text selection. Used for highlighting and copying to clipboard. - selection_end: Option<(u16, usize)>, - /// Configuration for the maximum amount of lines to keep in memory. - max_scrollback: usize, - /// Represents the current state for handling ansii escape sequences - /// as incoming data is being processed. - escape_state: EscapeState, - /// As ascii escape sequences are recieved, they are built in the - /// [`EscapeSequence`] to evaluate upon a completed escape sequence. - escape_sequence: EscapeSequence, - /// Represents the time since [`ScreenBuffer::render()`] was last called. - last_render: Option, - display_attributes: Attributes, - /// Indicates that [`ScreenBuffer`] has new data and needs to render. - needs_render: bool, -} - -impl ScreenBuffer { - /// Constructs a new `ScreenBuffer`. - /// - /// Takes the `width` and `height` of the terminal. - pub fn new(width: u16, height: u16) -> Self { - let mut buffer = Self { - width, - height, - lines: VecDeque::new(), - view_start: 0, - cursor_pos: Position::home(), - selection_start: None, - selection_end: None, - max_scrollback: MAX_SCROLLBACK, - last_render: None, - needs_render: false, - escape_state: EscapeState::Normal, - escape_sequence: EscapeSequence::new(), - display_attributes: Attributes::none(), - }; - // Start with an empty line - buffer.lines.push_back(Line::new(width as usize)); - buffer - } - - fn set_char_at_cursor(&mut self, ch: char) { - while self.cursor_pos.y >= self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); - } - - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) - && (self.cursor_pos.x as usize) < line.len() - { - line.set_char(self.cursor_pos.x as usize, ch); - } - } - - fn clear_from_cursor_to_sol(&mut self) { - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) { - line.reset_to(self.cursor_pos.x as usize); - } - } - - fn clear_from_cursor_to_sos(&mut self) { - self.clear_from_cursor_to_sol(); - for line in self - .lines - .range_mut(self.view_start..=self.cursor_pos.y - 1) - { - line.reset(); - } - } - - fn clear_from_cursor_to_eol(&mut self) { - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) { - line.reset_from(self.cursor_pos.x as usize); - } - } - - fn clear_from_cursor_to_eos(&mut self) { - self.clear_from_cursor_to_eol(); - for line in self.lines.range_mut(self.cursor_pos.y + 1..) { - line.reset(); - } - } - - fn clear_whole_line(&mut self) { - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) { - line.reset(); - } - } - - fn new_line(&mut self) { - self.set_cursor_pos((0, self.cursor_pos.y + 1)); - - if self.cursor_pos.y >= self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); - } - - // Remove old lines if exceeding `ScreenBuffer.max_scrollback` - while self.lines.len() > self.max_scrollback { - self.lines.pop_front(); - // Update the view position - if self.cursor_pos.y > 0 { - self.cursor_pos.y -= 1; - } - if self.view_start > 0 { - self.view_start -= 1; - } - } - } -} diff --git a/sericom-core/src/screen_buffer/render.rs b/sericom-core/src/screen_buffer/render.rs deleted file mode 100644 index 80d50d7..0000000 --- a/sericom-core/src/screen_buffer/render.rs +++ /dev/null @@ -1,228 +0,0 @@ -use crossterm::style::Color; -use std::io::BufWriter; -use tracing::instrument; - -use super::{Cursor, EscapeState, Line, ScreenBuffer, UIAction}; -use crate::configs::get_config; - -const MIN_RENDER_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_millis(33); - -impl ScreenBuffer { - /// Takes incoming data (bytes (`u8`) from a serial connection) and - /// processes them accordingly, handling ascii escape sequences, to - /// render as characters/strings in the terminal. - #[instrument(name = "Add Data", skip(self, data))] - pub fn add_data(&mut self, data: &[u8]) { - let text = String::from_utf8_lossy(data); - let mut chars = text.chars().peekable(); - - while let Some(ch) = chars.next() { - match self.escape_state { - EscapeState::Normal => { - match ch { - '\r' => { - self.cursor_pos.x = 0; - if chars.peek() == Some(&'\n') { - chars.next(); - self.new_line(); - } - } - '\n' => { - self.new_line(); - } - '\x07' => {} - '\x0E' => {} - '\x0F' => {} - '\x08' => { - let mut temp_chars = chars.clone(); - // Matches the `\x08 ' ' \x08` deletion sequence - if let (Some(' '), Some('\x08')) = - (temp_chars.next(), temp_chars.next()) - { - // Consume them - to remove from further processing - chars.next(); - chars.next(); - self.move_cursor_left(1); - self.set_char_at_cursor(' '); - } else { - // If not the deletion sequence, move cursor left - // when receiving a single '\x08' - self.move_cursor_left(1); - } - } - '\x1B' => self.escape_state = EscapeState::Esc, - c => { - let mut batch = vec![c]; - while let Some(&next_ch) = chars.peek() { - if next_ch.is_control() - || next_ch == '\x1B' - || self.cursor_pos.x + batch.len() as u16 >= self.width - { - break; - } - batch.push(chars.next().unwrap()); - } - self.add_char_batch(&batch); - } - } - } - EscapeState::Esc => match ch { - '[' => self.escape_state = EscapeState::Csi, - _ => self.escape_state = EscapeState::Normal, - }, - EscapeState::Csi => match ch { - ';' => self.escape_sequence.insert_separator(), - c if ch.is_ascii_digit() => self.escape_sequence.push_num(c), - c if c.is_ascii_alphabetic() => { - // Reset because actions are the last members of a sequence - self.escape_sequence.push_action(c); - self.parse_sequence(); - self.escape_sequence.reset(); - self.escape_state = EscapeState::Normal; - } - // NOTE: May need to handle '?', ':', and '>' - _ => self.escape_state = EscapeState::Normal, - }, - } - } - // Sets `self.needs_render = true` - self.scroll_to_bottom(); - } - - fn add_char_batch(&mut self, chars: &[char]) { - tracing::debug!("CharBatch: '{:?}'", chars); - while self.cursor_pos.y >= self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); - } - - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) { - for &ch in chars { - line.set_char(self.cursor_pos.x as usize, ch); - self.cursor_pos.x += 1; - if self.cursor_pos.x >= self.width { - self.new_line(); - break; - } - } - } - } - - /// A helper function to check whether the terminal's screen should be rendered. - pub fn should_render_now(&self) -> bool { - use tokio::time::Instant; - - if !self.needs_render { - return false; - } - - let now = Instant::now(); - match self.last_render { - Some(last) => now.duration_since(last) >= MIN_RENDER_INTERVAL, - None => true, - } - } - - /// Writes the lines/characters received from `add_data` to the terminal's screen. - /// - /// As of now, `render` does not involve any diff-ing of previous renders. - /// The nature of communicating to devices over a serial connection is similar - /// that of a terminal; lines get printed to a screen and with each new line, - /// all of the previously rendered characters must be re-rendered one cell higher. - /// - /// Because of this, the only diff-ing that would make sense would be - /// that of the cells within the screen that are simply blank. - pub fn render(&mut self) -> std::io::Result<()> { - use crossterm::{cursor, queue, style}; - use std::io::{self, Write}; - use tokio::time::Instant; - - if !self.needs_render { - return Ok(()); - } - - let mut writer = BufWriter::new(io::stdout()); - queue!(writer, cursor::Hide)?; - let config = get_config(); - - for screen_y in 0..self.height { - let line_idx = self.view_start + screen_y as usize; - queue!(writer, cursor::MoveTo(0, screen_y))?; - - if let Some(line) = self.lines.get_mut(line_idx) { - let mut current_fg = Color::from(&config.appearance.fg); - let mut current_bg = Color::from(&config.appearance.bg); - queue!( - writer, - style::SetForegroundColor(current_fg), - style::SetBackgroundColor(current_bg) - )?; - - for cell in line { - let global_reverse = self.display_attributes.has(style::Attribute::Reverse); - - let fg = if (cell.is_selected && !global_reverse) - || (!cell.is_selected && global_reverse) - { - cell.bg_color - } else { - cell.fg_color - }; - - let bg = if (cell.is_selected && !global_reverse) - || (!cell.is_selected && global_reverse) - { - cell.fg_color - } else { - cell.bg_color - }; - - if fg != current_fg { - queue!(writer, style::SetForegroundColor(fg))?; - current_fg = fg; - } - if bg != current_bg { - queue!(writer, style::SetBackgroundColor(bg))?; - current_bg = bg; - } - - if self.display_attributes.has(style::Attribute::Bold) { - queue!( - writer, - style::SetAttribute(style::Attribute::Bold), - style::Print(cell.character) - )?; - } else { - queue!(writer, style::Print(cell.character))?; - } - } - } else { - queue!( - writer, - style::ResetColor, - style::Print(" ".repeat(self.width as usize)) - )?; - } - } - - // This is relative the the terminal's L x W, whereas - // self.cursor_pos.y is within the entire line buf - let screen_cursor_y = if self.cursor_pos.y >= self.view_start - && self.cursor_pos.y < self.view_start + self.height as usize - { - (self.cursor_pos.y - self.view_start) as u16 - } else { - self.height - 1 - }; - - queue!( - writer, - cursor::MoveTo(self.cursor_pos.x, screen_cursor_y), - cursor::Show - )?; - writer.flush()?; - - self.last_render = Some(Instant::now()); - self.needs_render = false; - Ok(()) - } -} diff --git a/sericom-core/src/screen_buffer/ui_command.rs b/sericom-core/src/screen_buffer/ui_command.rs deleted file mode 100644 index f176c76..0000000 --- a/sericom-core/src/screen_buffer/ui_command.rs +++ /dev/null @@ -1,213 +0,0 @@ -use crate::screen_buffer::Position; - -use super::{Cursor, Line, ScreenBuffer}; - -/// `UICommand` is used for communication between stdin and the [`ScreenBuffer`]. -#[non_exhaustive] -#[derive(Clone, Debug)] -pub enum UICommand { - /// Scrolls up by `usize` lines - ScrollUp(usize), - /// Scrolls down by `usize` lines - ScrollDown(usize), - /// Scrolls to the last line (most recent) - ScrollBottom, - /// Scrolls to the beginning of the scrollback buffer (oldest line) - ScrollTop, - /// Starts text-selection at [`Position`] - StartSelection(Position), - /// Updates text-selection to [`Position`] - UpdateSelection(Position), - /// Copies the underlying selected text to the user's clipboard - CopySelection, - /// Completely clears the lines in the scrollback buffer - ClearBuffer, -} - -pub(crate) trait UIAction { - fn scroll_up(&mut self, lines: usize); - fn scroll_down(&mut self, lines: usize); - fn scroll_to_bottom(&mut self); - fn scroll_to_top(&mut self); - fn start_selection(&mut self, pos: Position); - fn update_selection(&mut self, pos: Position); - fn clear_selection(&mut self); - fn copy_to_clipboard(&mut self) -> std::io::Result<()>; - fn clear_buffer(&mut self); - fn clear_screen(&mut self); -} - -impl UIAction for ScreenBuffer { - /// Called to scroll the terminal up by `lines`. - fn scroll_up(&mut self, lines: usize) { - if self.view_start >= lines { - self.view_start -= lines; - } else { - self.view_start = 0; - } - self.clear_selection(); - self.needs_render = true; - } - - /// Called to scroll the terminal down by `lines`. - fn scroll_down(&mut self, lines: usize) { - let max_view_start = self.lines.len().saturating_sub(self.height as usize); - self.view_start = (self.view_start + lines).min(max_view_start); - self.clear_selection(); - self.needs_render = true; - } - - /// Scrolls to the bottom of the screen. The bottom of the screen is - /// the same as the most recent lines received from the serial connection - fn scroll_to_bottom(&mut self) { - self.view_start = self.lines.len().saturating_sub(self.height as usize); - self.needs_render = true; - } - - /// Scrolls to the top of the serial connection's history. - fn scroll_to_top(&mut self) { - self.view_start = 0; - self.needs_render = true; - } - - /// Sets the position within the screen for the start of a selection. - /// Where `screen_x` is the x-position of the start of the selection, - /// and `screen_y` is the y-position (line) of the start of the selection. - fn start_selection(&mut self, pos: Position) { - let absolute_line = self.view_start + pos.y; - self.clear_selection(); - self.selection_start = Some((pos.x, absolute_line)); - self.needs_render = true; - } - - /// Update's a selection to include the position passed to it. - /// Where `screen_x` is the x-position and `screen_y` is the y-position (line). - fn update_selection(&mut self, pos: Position) { - let absolute_line = self.view_start + pos.y; - self.selection_end = Some((pos.x, absolute_line)); - self.update_selection_highlighting(); - self.needs_render = true; - } - - /// Clears the selection state. - fn clear_selection(&mut self) { - for line in &mut self.lines { - line.clear_selection(); - } - self.selection_start = None; - self.selection_end = None; - self.needs_render = true; - } - - /// Copy's the currently selected text to the user's clipboard. - fn copy_to_clipboard(&mut self) -> std::io::Result<()> { - use crossterm::{clipboard, execute}; - - let selected_text = self.get_selected_text(); - if !selected_text.is_empty() { - execute!( - std::io::stdout(), - clipboard::CopyToClipboard::to_clipboard_from(selected_text) - )?; - } - self.clear_selection(); - Ok(()) - } - - /// Clears the entire serial connection's history and reset's the screen. - /// Similar to Ctrl + Shift + l in a terminal, - /// except this will reset the connection's message history (on the user's side). - fn clear_buffer(&mut self) { - self.lines.clear(); - self.view_start = 0; - self.set_cursor_pos((0_u16, 0_usize)); - self.lines.push_back(Line::new(self.width as usize)); - self.needs_render = true; - } - - /// Clears the current *visible* screen while keeping the buffer's history - fn clear_screen(&mut self) { - for _ in 0..self.height { - self.lines.push_back(Line::new(self.width as usize)); - } - self.view_start = self.lines.len().saturating_sub(self.height as usize); - self.needs_render = true; - } -} - -impl ScreenBuffer { - fn update_selection_highlighting(&mut self) { - for line in &mut self.lines { - line.clear_selection(); - } - - if let (Some((start_x, start_line)), Some((end_x, end_line))) = - (self.selection_start, self.selection_end) - { - let (start_line, start_x, end_line, end_x) = - // If start < end or if start = end, start x has to be less than end x - if start_line < end_line || (start_line == end_line && start_x <= end_x) { - (start_line, start_x, end_line, end_x) - } else { - (end_line, end_x, start_line, start_x) - }; - - for line_idx in start_line..=end_line { - if let Some(line) = self.lines.get_mut(line_idx) { - let line_start_x = if line_idx == start_line { start_x } else { 0 }; - let line_end_x = if line_idx == end_line { - end_x - } else { - self.width - 1 - }; - - for x in line_start_x..=line_end_x.min(self.width - 1) { - if let Some(cell) = line.get_mut_cell(x as usize) { - cell.is_selected = true; - } - } - } - } - } - } - - fn get_selected_text(&self) -> String { - if let (Some((start_x, start_line)), Some((end_x, end_line))) = - (self.selection_start, self.selection_end) - { - let (start_line, start_x, end_line, end_x) = - if start_line < end_line || (start_line == end_line && start_x <= end_x) { - (start_line, start_x, end_line, end_x) - } else { - (end_line, end_x, start_line, start_x) - }; - - let mut result = String::new(); - - for line_idx in start_line..=end_line { - if let Some(line) = self.lines.get(line_idx) { - let line_start_x = if line_idx == start_line { start_x } else { 0 }; - let line_end_x = if line_idx == end_line { - end_x - } else { - self.width - 1 - }; - - for x in line_start_x..=line_end_x.min(self.width - 1) { - if let Some(cell) = line.get_cell(x as usize) { - result.push(cell.character); - } - } - - if line_idx < end_line { - result.push('\n'); - } - } - } - - result.trim_end().to_string() - } else { - String::new() - } - } -} diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index fd83b5f..95a3adf 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -3,10 +3,11 @@ pub mod tasks; -/// Represents messages/commands that are sent from worker tasks -/// to the [`SerialActor`] to process. +use crate::SeriError; + +/// Represents messages/commands that are sent from worker tasks to the [`SerialActor`] to process. #[non_exhaustive] -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum SerialMessage { /// Instructs the [`SerialActor`] to write bytes (`Vec`) to the serial connection. Write(Vec), @@ -24,13 +25,20 @@ pub enum SerialEvent { /// Sends data received by the [`SerialActor`] to its tasks. Data(std::sync::Arc<[u8]>), /// Sends the error message received by the [`SerialActor`] to its tasks to handle. - Error(String), + Error(SeriError), /// Tells the [`SerialActor`]s tasks that the serial connection has been closed. + /// + /// This serves a different purpose from [`SerialMessage::Shutdown`] where + /// [`SerialMessage::Shutdown`] is mean to instruct the [`SerialActor`] to + /// shutdown the connection. `ConnectionClosed` is used for the [`SerialActor`] + /// to broadcast to listeners that the connection has been shutdown by the device. ConnectionClosed, + LinesWritten(u32), } /// Responsible for passing data and messages between the serial connection and tasks. -/// It uses the Actor model to maintain a single source for communicating between the +/// +/// Uses the Actor model to maintain a single source for communicating between the /// serial connection and tasks within the program. /// /// It broadcasts [`SerialEvent`]s to worker tasks via a [`tokio::sync::broadcast`] @@ -39,21 +47,22 @@ pub enum SerialEvent { pub struct SerialActor { connection: serial2_tokio::SerialPort, command_rx: tokio::sync::mpsc::Receiver, - broadcast_channel: tokio::sync::broadcast::Sender, + tasks_broadcast: tokio::sync::broadcast::Sender, } impl SerialActor { /// Constructs a [`SerialActor`] Takes a serial port connection, /// receiver to a command channel, and a sender to a broadcast channel. - pub fn new( + #[must_use] + pub const fn new( connection: serial2_tokio::SerialPort, command_rx: tokio::sync::mpsc::Receiver, - broadcast_channel: tokio::sync::broadcast::Sender, + tasks_broadcast: tokio::sync::broadcast::Sender, ) -> Self { Self { connection, command_rx, - broadcast_channel, + tasks_broadcast, } } @@ -66,41 +75,55 @@ impl SerialActor { /// /// Since data is sent byte-by-byte over a serial connection, `run` will /// batch the data before sending it to other tasks to reduce the number of syscalls. - pub async fn run(mut self) { - let mut buffer = vec![0u8; 4096]; + #[tracing::instrument(skip_all, level = "debug")] + pub async fn run(mut self) -> crate::Result<()> { + use tracing::{debug, error, trace}; + + trace!("running SerialActor"); + let mut buffer = vec![0u8; 2048]; loop { tokio::select! { // Handle commands/input from tasks cmd = self.command_rx.recv() => { + trace!(?cmd, "command_rx.recv()"); match cmd { Some(SerialMessage::Write(data)) => { if let Err(e) = self.connection.write_all(&data).await { - self.broadcast_channel.send(SerialEvent::Error(e.to_string())).ok(); + error!("error writing to connection: {e}"); + return Err(crate::SeriError::from(e).add_ctx("Error writing to the connection".into())); } } Some(SerialMessage::Shutdown) => { - self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); + debug!("recieved shutdown command"); + self.tasks_broadcast.send(SerialEvent::ConnectionClosed).ok(); + return Ok(()); } Some(SerialMessage::SendBreak) => { - self.send_break().await; + debug!("sending break signal"); + // self.send_break().await; + } + None => { + trace!("command_rx.recv() was None"); + return Err(SeriError::TaskError("No active tasks, shutting down.".into())); } - None => break, } } // Handle reading data from serial connection read_result = self.connection.read(&mut buffer) => { match read_result { Ok(0) => { - self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); - break; + debug!("closing connection - no bytes read"); + self.tasks_broadcast.send(SerialEvent::ConnectionClosed).ok(); + return Ok(()); } Ok(n) => { let data: std::sync::Arc<[u8]> = buffer[..n].into(); - self.broadcast_channel.send(SerialEvent::Data(data)).ok(); + trace!("recieved {} bytes", data.len()); + self.tasks_broadcast.send(SerialEvent::Data(data)).ok(); } Err(e) => { - self.broadcast_channel.send(SerialEvent::Error(e.to_string())).ok(); - break; + error!("error reading from connection: {e}"); + return Err(SeriError::from(e).add_ctx("Error reading from connection".into())); } } } @@ -108,7 +131,7 @@ impl SerialActor { } } - async fn send_break(&mut self) { + async fn send_break(&self) { use tokio::time::{Duration, sleep}; let _ = self.connection.set_break(true); sleep(Duration::from_millis(500)).await; diff --git a/sericom-core/src/serial_actor/tasks.rs b/sericom-core/src/serial_actor/tasks.rs index c507057..0ca89e6 100644 --- a/sericom-core/src/serial_actor/tasks.rs +++ b/sericom-core/src/serial_actor/tasks.rs @@ -1,15 +1,7 @@ -use super::*; -use crate::screen_buffer::*; -use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}, - terminal, -}; -use std::{ - fs::File, - io::{BufWriter, Write}, - path::PathBuf, -}; -use tracing::{error, info, instrument}; +use crate::{screen::UICommand, serial_actor::SerialMessage}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; + +use tracing::instrument; const UTF_TAB: &str = "\u{0009}"; const UTF_BKSP: &str = "\u{0008}"; @@ -21,105 +13,6 @@ const UTF_DOWN_KEY: &str = "\u{001B}\u{005B}\u{0042}"; const UTF_LEFT_KEY: &str = "\u{001B}\u{005B}\u{0044}"; const UTF_RIGHT_KEY: &str = "\u{001B}\u{005B}\u{0043}"; -/// Responsible for receiving incoming data from the [`SerialActor`] and -/// rendering terminal output via the [`ScreenBuffer`]. -#[instrument(skip_all, name = "Stdout")] -pub async fn run_stdout_output( - mut con_rx: tokio::sync::broadcast::Receiver, - mut ui_rx: tokio::sync::mpsc::Receiver, -) { - let (width, height) = terminal::size().unwrap_or((80, 24)); - let mut screen_buffer = ScreenBuffer::new(width, height); - let mut data_buffer = Vec::with_capacity(2048); - let mut render_timer: Option = None; - - loop { - tokio::select! { - serial_event = con_rx.recv() => { - match serial_event { - Ok(SerialEvent::Data(data)) => { - data_buffer.extend_from_slice(&data); - - if data_buffer.len() > 1024 || data.contains(&b'\n') { - screen_buffer.add_data(&data_buffer); - data_buffer.clear(); - - if screen_buffer.should_render_now() { - screen_buffer.render().ok(); - render_timer = None; - } else if render_timer.is_none() { - render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); - } - } else { - screen_buffer.add_data(&data_buffer); - data_buffer.clear(); - - if screen_buffer.should_render_now() { - screen_buffer.render().ok(); - } else if render_timer.is_none() { - render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); - } - } - } - Ok(SerialEvent::Error(e)) => { - let error_msg = format!("[ERROR] {e}\r\n"); - error!("Added data: {:?}", data_buffer); - screen_buffer.add_data(error_msg.as_bytes()); - screen_buffer.render().ok(); - render_timer = None; - } - Ok(SerialEvent::ConnectionClosed) => break, - Err(_) => break, - } - } - ui_command = ui_rx.recv() => { - // debug!("Sending UICommand: {:?}", ui_command); - match ui_command { - Some(UICommand::ScrollUp(lines)) => { - screen_buffer.scroll_up(lines); - } - Some(UICommand::ScrollDown(lines)) => { - screen_buffer.scroll_down(lines); - } - Some(UICommand::ScrollTop) => { - screen_buffer.scroll_to_top(); - } - Some(UICommand::ScrollBottom) => { - screen_buffer.scroll_to_bottom(); - } - Some(UICommand::StartSelection(pos)) => { - screen_buffer.start_selection(pos); - } - Some(UICommand::UpdateSelection(pos)) => { - screen_buffer.update_selection(pos); - } - Some(UICommand::CopySelection) => { - screen_buffer.copy_to_clipboard().ok(); - } - Some(UICommand::ClearBuffer) => { - screen_buffer.clear_buffer(); - } - None => break, - } - screen_buffer.render().ok(); - render_timer = None; - } - _ = async { - if let Some(ref mut timer) = render_timer { - timer.tick().await; - } else { - std::future::pending::<()>().await - } - } => { - if screen_buffer.should_render_now() { - screen_buffer.render().ok(); - render_timer = None; - } - } - } - } -} - /// Responsible for spawning a blocking task with [`tokio::task::spawn_blocking()`] /// and processing user input from stdin. /// @@ -146,6 +39,7 @@ pub async fn run_stdin_input( } } +#[allow(clippy::too_many_lines)] #[instrument(skip_all, name = "Stdin Input")] fn stdin_input_loop( stdin_tx: tokio::sync::mpsc::Sender, @@ -173,8 +67,7 @@ fn stdin_input_loop( let _ = ui_tx.blocking_send(UICommand::ScrollBottom); } _ => {} - }; - continue; + } } // Match Alt + Code Event::Key(KeyEvent { @@ -186,10 +79,9 @@ fn stdin_input_loop( if kind != crossterm::event::KeyEventKind::Press { continue; } - if let KeyCode::Char('b') = code { + if KeyCode::Char('b') == code { let _ = command_tx.blocking_send(SerialMessage::SendBreak); - }; - continue; + } } // Match Control + Code Event::Key(KeyEvent { @@ -213,8 +105,7 @@ fn stdin_input_loop( break; } _ => {} - }; - continue; + } } // Match every other key Event::Key(KeyEvent { @@ -268,97 +159,3 @@ fn stdin_input_loop( } } } - -/// Responsible for spawning a blocking task with [`tokio::task::spawn_blocking()`] -/// and forwarding the incoming data received from the [`SerialActor`] to the blocking -/// task to write to a file. -#[instrument(name = "File output", skip(file_rx))] -pub async fn run_file_output( - mut file_rx: tokio::sync::broadcast::Receiver, - file_path: PathBuf, -) { - let (write_tx, write_rx) = std::sync::mpsc::channel::>(); - info!("Creating file: '{}'", file_path.display()); - let write_handle = tokio::task::spawn_blocking(move || { - let file = match File::create(&file_path) { - Ok(f) => f, - Err(e) => { - eprintln!("Failed to create file '{}': {e}", file_path.display()); - return; - } - }; - let mut writer = BufWriter::with_capacity(8 * 1024, file); - let mut last_flush = std::time::Instant::now(); - - writeln!(writer, "Session started at: {}", chrono::Utc::now()).ok(); - while let Ok(data) = write_rx.recv() { - writer.write_all(&data).ok(); - let now = std::time::Instant::now(); - if now.duration_since(last_flush) > std::time::Duration::from_millis(200) - || writer.buffer().len() > 4 * 1024 - { - let _ = writer.flush(); - last_flush = now; - } - } - let _ = writer.flush(); - }); - - let data_streamer = tokio::spawn(async move { - let mut write_buf = Vec::with_capacity(4096); - let mut batch_timer = tokio::time::interval(tokio::time::Duration::from_millis(200)); - - loop { - tokio::select! { - event = file_rx.recv() => { - match event { - Ok(SerialEvent::Data(data)) => { - write_buf.extend_from_slice(&data); - if write_buf.len() >= 4096 && write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - } - Ok(SerialEvent::Error(e)) => { - if !write_buf.is_empty() { - if write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - write_buf.clear(); - } - let error_msg = format!("\r\n[ERROR {}] {e}\r\n", chrono::Utc::now()); - let _ = write_tx.send(error_msg.into_bytes()); - } - Ok(SerialEvent::ConnectionClosed) => { - if !write_buf.is_empty() { - if write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - write_buf.clear(); - } - let close_msg = format!("\r\n[CLOSED {}] Connection closed.\r\n", chrono::Utc::now()); - let _ = write_tx.send(close_msg.into_bytes()); - break; - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - eprintln!("File writer lagged, skipped {skipped} messages"); - continue; // Don't break on lag - } - _ => break, - } - } - _ = batch_timer.tick() => { - if !write_buf.is_empty() && write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - } - } - } - if !write_buf.is_empty() { - let _ = write_tx.send(std::mem::take(&mut write_buf)); - } - drop(write_tx); - }); - - let _ = data_streamer.await; - let _ = write_handle.await; -} diff --git a/sericom-core/src/session/error.rs b/sericom-core/src/session/error.rs new file mode 100644 index 0000000..b56a8ee --- /dev/null +++ b/sericom-core/src/session/error.rs @@ -0,0 +1,172 @@ +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Debug, Clone, Diagnostic, Error)] +pub enum SeriError { + #[error("Parsing error: {0:?}")] + Parsing(String), + #[error("Invalid session id: {0}")] + Session(super::SessionID), + #[error("Task error: {0:?}")] + TaskError(String), + #[error("{}", ctx.as_ref().map_or("I/O Error", |c| c))] + Io { + #[source] + source: std::sync::Arc, + #[help] + help: Option, + ctx: Option, + }, + #[error("Serial connection error")] + Connection { + #[source] + source: std::sync::Arc, + #[help] + help: Option, + }, + #[error("Serial connection error")] + SendErr(#[from] tokio::sync::mpsc::error::SendError), +} + +#[allow(unused)] +impl SeriError { + #[must_use] + pub fn add_ctx(mut self, msg: String) -> Self { + match &mut self { + Self::TaskError(_) => todo!(), + Self::Io { source, help, ctx } => *ctx = Some(msg), + Self::Connection { source, help } => todo!(), + Self::SendErr(_) => todo!(), + Self::Session(_) => todo!(), + Self::Parsing(_) => todo!(), + } + + self + } + + #[must_use] + pub fn add_help(mut self, msg: String) -> Self { + match &mut self { + Self::TaskError(_) => todo!(), + Self::Io { source, help, ctx } => *help = Some(msg), + Self::Connection { source, help } => *help = Some(msg), + Self::SendErr(_) => todo!(), + Self::Session(_) => todo!(), + Self::Parsing(_) => todo!(), + } + + self + } +} + +impl From for SeriError { + fn from(err: std::io::Error) -> Self { + Self::Io { + source: std::sync::Arc::new(err), + help: None, + ctx: None, + } + } +} + +#[macro_export] +macro_rules! err_into { + ($err:expr, $variant:path) => {{ + $variant { + source: std::sync::Arc::new($err), + help: None, + } + }}; + + ($err:expr, $variant:path, help = $help:expr) => {{ + $variant { + source: $err, + help: $help, + } + }}; + + ($err:expr, $variant:path, help = $help:expr) => {{ + $variant { + source: $err, + help: $help, + } + }}; +} + +#[macro_export] +macro_rules! with_help { + ($err:expr, $msg:literal) => {{ + match $err { + $crate::SeriError::Io { source, .. } => { + $crate::SeriError::Io { + source, + help: Some($msg.into()), + ctx: None, + } + } + $crate::SeriError::Connection { source, .. } => { + $crate::SeriError::Connection { + source, + help: Some($msg.into()), + } + } + _ => $err, + } + }}; + ($err:expr, $msg:literal , $($args:expr),* ) => {{ + match $err { + $crate::SeriError::Io { source, .. } => { + $crate::SeriError::Io { + source, + help: Some(format!($msg, $($args),*)), + ctx: None, + } + } + $crate::SeriError::Connection { source, .. } => { + $crate::SeriError::Connection { + source, + help: Some(format!($msg, $($args),*)), + } + } + _ => $err, + } + }}; + ($var:path [ $err:expr ] : $msg:literal , $($args:expr),*) => {{ + $var { + source: $err, + help: Some(format!($msg, $($args),*)), + } + }}; + + ($var:path [ $err:expr ] : $msg:literal $($more_msg:literal)* , $($args:expr),*) => {{ + $var { + source: $err, + help: Some(format!(concat!($msg, $("\n", $more_msg),+), $($args),*)), + } + }}; +} + +#[test] +fn test_macro() { + fn err() -> miette::Result<()> { + let e = std::io::Error::last_os_error(); + // let err: SeriError = with_help!( + // SeriError::ConnectionIo[e]: "hello {} {} {}" + // "this is another line {}" + // "and another line {}", + // "world", 1, 2, 3, 4 + // ); + // let err: SeriError = with_help!( + // SeriError::ConnectionIo[e]: "hello {} {} {}", 1, 2, 3 + // ); + let err = SeriError::Io { + source: std::sync::Arc::new(e), + help: None, + ctx: None, + }; + + Err(err).map_err(|e| with_help!(e, "hello {} {} {}", 1, 2, 3).into()) + } + + err().inspect_err(|err| eprintln!("{err:?}")).ok(); +} diff --git a/sericom-core/src/session/handle.rs b/sericom-core/src/session/handle.rs new file mode 100644 index 0000000..64f5d1a --- /dev/null +++ b/sericom-core/src/session/handle.rs @@ -0,0 +1,409 @@ +use miette::Context; +use std::{path::PathBuf, sync::Arc}; +use tokio::{io::AsyncWriteExt, sync::oneshot, task::JoinSet}; +use tracing::{Instrument, Span, debug, error, info, trace, warn}; + +use crate::{ + SeriError, + cli::open_connection, + compat_port_path, + configs::get_config, + create_recursive, + screen::{Rect, ScreenBuffer}, + serial_actor::{SerialActor, SerialEvent, SerialMessage}, + with_help, +}; + +/// A handle to a session - used to manage the session in headless/interactive modes. +#[derive(Debug)] +pub struct SessionHandle { + /// The Session's dedicated [`ScreenBuffer`]. + pub(crate) buffer: Arc>, + /// Channel for communication from outside the session to the session. + /// + /// A [`Sender`] so that the [`SessionHandle`] can send [`Write`], [`SendBreak`], + /// and [`Shutdown`] events to the session's [`SerialActor`]. + /// + /// [`Sender`]: tokio::sync::mpsc::Sender + /// [`Write`]: crate::serial_actor::SerialMessage::Write + /// [`SendBreak`]: crate::serial_actor::SerialMessage::SendBreak + /// [`Shutdown`]: crate::serial_actor::SerialMessage::Shutdown + pub(crate) tx: tokio::sync::mpsc::Sender, + /// A [`Receiver`] of the session's [`SerialEvent`]s. + /// + /// Intended for the [`SessionManager`] to recieve these events based on the + /// session's state and where it's received data is being written to handle accordingly. + /// + /// [`Receiver`]: tokio::sync::broadcast::Receiver + /// [`SessionManager`]: super::SessionManager + pub(crate) events_tx: tokio::sync::broadcast::Sender, + /// A sessions async tasks. + /// + /// Currently a session only has two tasks, the [`SerialActor::run`] and + /// the task responsible for parsing the session's incoming data/byte stream. + /// The [`JoinSet`] is used so that when a session is terminated, all tasks + /// associated with a session can be gracefully killed together. + pub(crate) tasks: JoinSet>, +} + +async fn instrument_task( + fut: F, + span: Option, + events: tokio::sync::broadcast::Sender, +) -> crate::Result<()> +where + F: Future> + Send + 'static, +{ + let res = if let Some(span) = span { + fut.instrument(span).await + } else { + fut.await + }; + + if let Err(ref e) = res { + let _ = events.send(SerialEvent::Error(e.clone())); + } + res +} + +impl SessionHandle { + /// Spawn a new session and optionally stream to a `file` or run in headless mode. + pub fn spawn( + meta: &super::SessionMeta, + with_file: Option>, + headless: bool, + mgr_tx: oneshot::Sender, + ) -> miette::Result { + let (tx, rx) = tokio::sync::mpsc::channel::(100); + let (events_tx, _) = tokio::sync::broadcast::channel::(128); + + let connection = open_connection(meta.baud, &meta.port) + .map_err(|e| with_help!(e, "Is the port already open? Do you have permission? Typo?")) + .wrap_err(format!("Failed to open port: '{}'", meta.port.display()))?; + let (term_w, term_h) = if headless { + #[cfg(not(test))] + { + (120, 24) + } + #[cfg(test)] + { + (120, 10) + } + } else { + crossterm::terminal::size().unwrap_or((80, 24)) + }; + + let sb = ScreenBuffer::new(Rect::new((0u16, 0u16).into(), term_w, term_h)); + let buffer = Arc::new(tokio::sync::RwLock::new(sb)); + let actor = SerialActor::new(connection, rx, events_tx.clone()); + + let span = { + let info = tracing::info_span!("session"); + let dbg = tracing::debug_span!("session", port = %meta.port.display()); + if dbg.is_disabled() { info } else { dbg } + }; + let _enter = span.enter(); + debug!(port=%meta.port.display(), baud=%meta.baud, "opened connection"); + + let mut handle = Self { + buffer, + tx, + events_tx, + tasks: JoinSet::new(), + }; + handle.spawn_monitor_task(mgr_tx); + handle.spawn_actor_task(actor); + handle.spawn_parse_task(); + + if with_file.is_some() { + let config = get_config()?; + let default_out_dir = PathBuf::from(&config.defaults.out_dir); + let file_path = if let Some(Some(path)) = with_file { + // If given an absolute path - override the `default_out_dir` + if path.is_absolute() { + let parent = path.parent().unwrap_or(&default_out_dir); + create_recursive!(parent); + path + } else { + let joined_path = default_out_dir.join(&path); + let parent_path = joined_path.parent().expect("Does not have root"); + create_recursive!(parent_path); + joined_path + } + } else { + let default_out_dir = PathBuf::from(&config.defaults.out_dir); + drop(config); + compat_port_path!(default_out_dir, &meta.port) + }; + handle.spawn_file_task(file_path); + } + + Ok(handle) + } + + /// Shutdown this session. + /// + /// Signals associated tasks to shutdown and waits for them to complete. + pub async fn shutdown(self) { + if let Err(e) = self.tx.send(SerialMessage::Shutdown).await { + warn!("error sending shutdown to actor: {e}"); + let mut tasks = self.tasks; + tasks.abort_all(); + } else { + self.tasks.join_all().await; + } + trace!("all tasks finished, shutting down"); + } + + /// Monitors the tasks' broadcast channel for ones that send an error. + /// + /// Upon receiving an error, propogates to the [`SessionManager`] to handle. + /// + /// [`SessionManager`]: super::SessionManager + fn spawn_monitor_task(&mut self, mgr_tx: oneshot::Sender) { + let (tasks_tx, mut mon_rx) = (self.tx.clone(), self.events_tx.subscribe()); + let span = tracing::Span::current(); + self.tasks.spawn( + async move { + while let Ok(event) = mon_rx.recv().await { + match event { + SerialEvent::Error(e) => { + error!(err=%e, "task failed"); + let _ = mgr_tx.send(e); + let _ = tasks_tx.send(SerialMessage::Shutdown).await; + break; // so compiler doesn't complain about mgr_tx being moved + } + SerialEvent::ConnectionClosed => break, + _ => {} + } + } + + Ok(()) + } + .instrument(span), + ); + } + + /// Spawns a task that runs the [`SerialActor`] for the session. + fn spawn_actor_task(&mut self, actor: SerialActor) { + self.tasks.spawn(instrument_task( + actor.run(), + Some(Span::current()), + self.events_tx.clone(), + )); + } + + /// Spawns a task that writes the session's output to a file. + fn spawn_file_task(&mut self, f_path: PathBuf) { + self.tasks.spawn(instrument_task( + file_task(f_path, Arc::clone(&self.buffer), self.events_tx.subscribe()), + Some(Span::current()), + self.events_tx.clone(), + )); + } + + /// Spawn the parsing task. + /// + /// This task is responsible for parsing the session's incoming data and + /// writing it to the sessions [`ScreenBuffer`]. + fn spawn_parse_task(&mut self) { + self.tasks.spawn(instrument_task( + parse_task( + Arc::clone(&self.buffer), + self.events_tx.clone(), + self.events_tx.subscribe(), + ), + Some(Span::current()), + self.events_tx.clone(), + )); + } +} + +#[tracing::instrument(skip_all)] +async fn file_task( + f_path: PathBuf, + buffer: Arc>, + mut events_rx: tokio::sync::broadcast::Receiver, +) -> crate::Result<()> { + let mut file = tokio::fs::File::create(&f_path) + .await + .inspect_err(|_| { + error!("failed to create file: {}", f_path.display()); + }) + .map_err(|e| { + SeriError::from(e).add_ctx(format!("Failed to create file: {}", f_path.display())) + })?; + + info!(file=%f_path.display(), "created file"); + let mut last_idx = buffer.read().await.view_start; + + while let Ok(event) = events_rx.recv().await { + match event { + SerialEvent::LinesWritten(0) => {} + SerialEvent::LinesWritten(1) => { + trace!("LinesWritten=1"); + let sb = buffer.read().await; + if sb.view_start.saturating_sub(1) == last_idx { + continue; + } + last_idx += 1; + + trace!(%last_idx, view_start=%sb.view_start); + + if let Some(line) = sb.lines.get(last_idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .map_err(|e| { + SeriError::from(e).add_ctx("Failed to write to file".into()) + })?; + } + drop(sb); + } + SerialEvent::LinesWritten(num) => { + trace!("LinesWritten={num}"); + let sb = buffer.read().await; + let last_line_idx = sb.view_start.saturating_sub(1); + let range = { + let end = if last_idx + num > last_line_idx { + last_line_idx + } else { + last_idx + num + }; + + let r = if last_idx == 0 { + last_idx..=end + } else { + last_idx + 1..=end + }; + + trace!( + %last_idx, + view_start=%sb.view_start, + %last_line_idx, + ?r + ); + + last_idx += num; + r + }; + + for idx in range { + if let Some(line) = sb.lines.get(idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .map_err(|e| { + SeriError::from(e).add_ctx("Failed to write to file".into()) + })?; + } + } + } + SerialEvent::ConnectionClosed => { + let sb = buffer.read().await; + let viewport = sb.buff_rect(); + trace!(range=?sb.view_start..=(viewport.height + sb.view_start), "flushing"); + for idx in sb.view_start..(viewport.height + sb.view_start) { + if let Some(line) = sb.lines.get(idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .map_err(|e| { + SeriError::from(e).add_ctx("Failed to write to file".into()) + })?; + } + } + file.flush() + .await + .map_err(|e| SeriError::from(e).add_ctx("Failed to flush to file".into()))?; + trace!("connection closed"); + break; + } + _ => {} + } + } + Ok(()) +} + +#[tracing::instrument(skip_all, level = "debug")] +async fn parse_task( + buffer: Arc>, + events_tx: tokio::sync::broadcast::Sender, + mut events_rx: tokio::sync::broadcast::Receiver, +) -> crate::Result<()> { + use crate::screen::{ByteParser, ScreenDriver}; + + let mut parser = ByteParser::new(); + while let Ok(event) = events_rx.recv().await { + match event { + SerialEvent::Data(bytes) => { + trace!("received {} bytes", bytes.len()); + let parsed = parser.feed(&bytes); + let mut buf = buffer.write().await; + + let num_lines = ScreenDriver::new(&mut buf).process_events(parsed); + drop(buf); + let _ = events_tx.send(SerialEvent::LinesWritten(num_lines)); + } + SerialEvent::ConnectionClosed => { + trace!("connection closed"); + break; + } + _ => {} + } + } + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use std::{ + env::{current_dir, temp_dir}, + fs::{File, remove_file}, + io::{BufRead, BufReader, Write}, + path::PathBuf, + time::Duration, + }; + use tokio::time::sleep; + + use super::super::*; + use test_sericom::get_pts_pair; + + #[tokio::test] + #[test_log::test] + async fn write_a_file() { + crate::configs::init_for_tests(); + + let bytes = include_bytes!("./mod.rs"); + assert!(!bytes.is_empty()); + let tmp_dir = temp_dir(); + let tmp_path = tmp_dir.join("sericom_core_test_write_a_file.txt"); + let og_path = current_dir().unwrap().join("src/session/mod.rs"); + + let mut manager = SessionManager::new(); + let (mut master, slave) = get_pts_pair().unwrap(); + + let id = manager + .spawn( + PathBuf::from(slave), + 9600, + Some(Some(tmp_path.clone())), + true, + ) + .unwrap(); + + sleep(Duration::from_millis(1)).await; + master.write_all(bytes).unwrap(); + sleep(Duration::from_millis(1)).await; + + manager.kill(id).await; + + let out_file = BufReader::new(File::open(&tmp_path).unwrap()); + let og_file = BufReader::new(File::open(&og_path).unwrap()); + let out_lines = out_file.lines().map(Result::unwrap); + let og_lines = og_file.lines().map(Result::unwrap); + + for (og_line, out_line) in og_lines.zip(out_lines) { + assert_eq!(og_line, out_line); + } + + let _ = remove_file(tmp_path); + } +} diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs new file mode 100644 index 0000000..b5afede --- /dev/null +++ b/sericom-core/src/session/mod.rs @@ -0,0 +1,325 @@ +use std::sync::Arc; +use tokio::{sync::oneshot, task::JoinHandle}; +use tracing::{info, warn}; + +mod error; +mod handle; +pub use error::SeriError; +pub use handle::SessionHandle; + +use crate::serial_actor::SerialMessage; + +pub type SessionID = u8; +pub type UpdatedId = (SessionID, SessionID); + +#[derive(Debug, Default)] +pub struct SessionManager { + handles: Vec, + metas: Vec, + monitors: Vec>, + errors: Arc>>, + shutting_down: Arc, + id_update_tx: tokio::sync::watch::Sender>, + active: Option, +} + +/// Includes the baud rate and name of the port/connection. +#[derive(Debug, Clone)] +pub struct SessionMeta { + pub(crate) baud: u32, + pub(crate) port: std::path::PathBuf, +} + +impl SessionManager { + #[must_use] + pub fn new() -> Self { + let (id_update_tx, _) = tokio::sync::watch::channel(None); + Self { + active: None, + // Creating with capacity of ~25 because if someone has more than 25 + // sessions they can get an allocation - majority will have < 25 sessions + handles: Vec::with_capacity((u8::MAX / 10u8) as usize), + metas: Vec::with_capacity((u8::MAX / 10u8) as usize), + monitors: Vec::with_capacity((u8::MAX / 10u8) as usize), + errors: Arc::new(parking_lot::Mutex::new(Vec::with_capacity(8))), + id_update_tx, + shutting_down: Arc::new(tokio::sync::Notify::new()), + } + } + + pub async fn send(&mut self, id: SessionID, msg: &str) -> crate::Result<()> { + if let Some(session) = self.get_mut_session(id) { + let mut msg = msg.as_bytes().to_vec(); + msg.push(b'\n'); + session + .tx + .send(SerialMessage::Write(msg)) + .await + .map_err(SeriError::from) + } else { + Err(SeriError::Session(id)) + } + } + + /// Get a reference to the [`SessionHandle`] of [`SessionID`]. + /// + /// Returns `None` if the [`SessionID`] is invalid. + #[must_use] + pub fn get_session(&self, id: SessionID) -> Option<&SessionHandle> { + self.handles.get(id as usize) + } + + #[must_use] + pub fn get_mut_session(&mut self, id: SessionID) -> Option<&mut SessionHandle> { + self.handles.get_mut(id as usize) + } + + /// Get a reference to the [`SessionMeta`] of [`SessionID`]. + /// + /// Returns `None` if the [`SessionID`] is invalid. + #[must_use] + pub fn get_meta(&self, id: SessionID) -> Option<&SessionMeta> { + self.metas.get(id as usize) + } + + /// Create a session at `port` with the specified `baud` rate. + /// + /// # Errors + /// Errors if there are already a maximum number of sessions ([`u8::MAX`]) or + /// if the [`SessionHandle::spawn`] errors. + #[allow(clippy::cast_possible_truncation)] + pub fn spawn( + &mut self, + port: std::path::PathBuf, + baud: u32, + f_path: Option>, + headless: bool, + ) -> miette::Result { + let id = self.metas.len(); + + if id >= u8::MAX as usize { + warn!(%id, "max sessions reached"); + return Err(miette::miette!("Max sessions reached"))?; + } + + let (err_tx, err_rx) = oneshot::channel::(); + let meta = SessionMeta { baud, port }; + let handle = SessionHandle::spawn(&meta, f_path, headless, err_tx)?; + info!(%id, port=%meta.port.display(), %baud, "created session"); + + self.handles.push(handle); + self.metas.push(meta); + self.start_monitor(id as SessionID, err_rx); + + // Cast is fine, verified that id is < u8::MAX + Ok(id as SessionID) + } + + /// Kill/shutdown the session for [`SessionID`] + #[allow(clippy::cast_possible_truncation)] + pub async fn kill(&mut self, id: SessionID) { + let idx = id as usize; + if idx >= self.metas.len() { + warn!("invalid session id: '{id}'"); + return; + } + + if self.handles.get(idx).is_none() { + warn!("session '{id}' does not exist"); + return; + } + + let meta = self.metas.swap_remove(idx); + self.handles.swap_remove(idx).shutdown().await; + + let id_to_update = self.monitors.len().saturating_sub(1) as SessionID; + self.monitors.swap_remove(idx).abort(); + self.id_update_tx + .send(Some((id_to_update, idx as SessionID))) + .ok(); + + info!( + %id, + port=%meta.port.display(), + "terminated session" + ); + + // NOTE: + // Shouldn't need to adjust active idx because when the user is in the + // REPL, there should not be an active session; and this should only + // be possible to call from the REPL. Check anyways in the off-chance + // that this is not the case. + debug_assert!(self.active.is_none()); + if let Some(active) = self.active { + let a_idx = active as usize; + if a_idx == idx { + self.active = None; + } else if a_idx == self.metas.len() { + // Just moved last item into idx, renumber active to maintain + // Cast is fine, verified that id is < u8::MAX + self.active = Some(idx as SessionID); + } + } + } + + /// List the sessions stored in [`SessionManager`], writes to the given [`Writer`]. + /// + /// Writes in the following format: + /// ```txt + /// ID PORT BAUD + /// 1 /dev/ttyUSB1 9600 + /// 2 /dev/ttyUSB2 115200 + /// ``` + /// + /// [`Writer`]: std::io::Write + pub fn list(&self, writer: &mut W) { + #[cfg(target_os = "windows")] + { + let _ = writeln!(writer, "{:3} {:6} BAUD", "ID", "PORT"); + for (id, meta) in self.metas.iter().enumerate() { + let _ = writeln!(writer, "{:<3} {:6} {}", id, meta.port.display(), meta.baud); + } + } + + #[cfg(not(target_os = "windows"))] + { + let _ = writeln!(writer, "{:3} {:14} BAUD", "ID", "PORT"); + for (id, meta) in self.metas.iter().enumerate() { + let _ = writeln!(writer, "{:<3} {:14} {}", id, meta.port.display(), meta.baud); + } + } + } + + fn start_monitor(&mut self, id: SessionID, err_rx: oneshot::Receiver) { + let mon = tokio::task::spawn(run_monitor( + id, + Arc::clone(&self.shutting_down), + self.id_update_tx.subscribe(), + Arc::clone(&self.errors), + err_rx, + )); + + self.monitors.push(mon); + } + + pub async fn graceful_shutdown(self) { + let mut idx: u8 = 0; + self.shutting_down.notify_waiters(); + + for session in self.handles { + idx += 1; + session.shutdown().await; + info!( + id=%(idx as usize).saturating_sub(1), + port=%self.metas[(idx as usize).saturating_sub(1)].port.display(), + "terminated session" + ); + } + } + + /// Provides access to any errors the [`SessionManager`] has collected. + /// + /// *Note:* If there is an active session, this will simply return without calling + /// the provided function. See [`force_check_errors()`] for unconditional + /// access to errors. + /// + /// [`force_check_errors()`]: SessionManager::force_check_errors + pub async fn check_errors(&mut self, f: F) + where + F: FnOnce(&[(SessionID, miette::Report)]), + { + let drained = { + let mut errors = self.errors.lock(); + if errors.is_empty() || self.active.is_some() { + return; + } + errors + .drain(..) + .collect::>() + }; + + f(&drained); + + for (id, _) in drained { + self.kill(id).await; + } + } + + /// Provides access to any errors the [`SessionManager`] has collected. + /// + /// Unlike [`check_errors()`], this will unconditionally give access to errors, if any. + /// + /// [`check_errors()`]: SessionManager::check_errors + pub async fn force_check_errors(&mut self, f: F) + where + F: FnOnce(&[(SessionID, miette::Report)]), + { + let drained = { + let mut errors = self.errors.lock(); + if errors.is_empty() { + return; + } + errors + .drain(..) + .collect::>() + }; + + f(&drained); + + for (id, _) in drained { + self.kill(id).await; + } + } +} + +async fn run_monitor( + id: SessionID, + shutting_down: Arc, + mut id_updated: tokio::sync::watch::Receiver>, + errors: Arc>>, + err_rx: tokio::sync::oneshot::Receiver, +) { + let mut err_rx = Box::pin(err_rx); + let mut id = id; + + loop { + tokio::select! { + () = shutting_down.notified() => break, + changed = id_updated.changed() => { + match changed { + Ok(()) => { + if let Some(update) = *id_updated.borrow() + && update.0 == id + { + id = update.1; + } + } + Err(_) => break, + } + }, + msg = &mut err_rx => { + match msg { + Ok(err) => { + errors.lock().push((id, miette::Report::from(err))); + return; + } + Err(_) => break, + } + } + } + } +} + +#[cfg(test)] +#[allow(unused)] +impl SessionManager { + pub(crate) async fn assert_buf(&self, id: SessionID, f: F) + where + F: FnOnce(&crate::screen::ScreenBuffer), + { + if let Some(session) = self.get_session(id) { + let sb = session.buffer.read().await; + f(&sb); + } + } +} diff --git a/sericom-core/src/ui/buffer.rs b/sericom-core/src/ui/buffer.rs new file mode 100644 index 0000000..ee511ca --- /dev/null +++ b/sericom-core/src/ui/buffer.rs @@ -0,0 +1,28 @@ +#![allow(unused)] +use crate::screen::{Cell, Line, Rect, Span, TermPos}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Buffer { + area: Rect, + content: Vec, +} +impl Buffer { + pub fn reset(&mut self) { + // for line in &mut self.content { + // line.reset(); + // } + } + #[must_use] + pub fn empty(area: Rect) -> Self { + Self::filled(area, Span::default()) + } + + #[must_use] + pub fn filled(area: Rect, span: Span) -> Self { + // let line = Line::new(area.width.into(), span); + let line = Line::new_empty(area.width.into()); + let size = area.height as usize; + let content = vec![line; size]; + Self { area, content } + } +} diff --git a/sericom-core/src/ui/frame.rs b/sericom-core/src/ui/frame.rs new file mode 100644 index 0000000..cda49a9 --- /dev/null +++ b/sericom-core/src/ui/frame.rs @@ -0,0 +1,38 @@ +#![allow(unused)] + +use crate::screen::TermPos; +use crate::{screen::ScreenBuffer, ui::Buffer}; + +use crate::{screen::Position, screen::Rect}; + +#[derive(Debug)] +pub struct Frame<'a> { + pub(crate) buffer: &'a mut Buffer, + pub(crate) cursor_position: Option>, + pub(crate) area: Rect, +} + +impl Frame<'_> { + #[must_use] + pub const fn area(&self) -> Rect { + self.area + } + + pub fn render_widget(&mut self, widget: W, area: Rect) { + widget.render(area, self.buffer); + } +} + +pub trait Widget { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized; +} + +impl Widget for ScreenBuffer { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + } +} diff --git a/sericom-core/src/ui/mod.rs b/sericom-core/src/ui/mod.rs new file mode 100644 index 0000000..e5d4242 --- /dev/null +++ b/sericom-core/src/ui/mod.rs @@ -0,0 +1,7 @@ +mod buffer; +mod frame; +mod terminal; + +pub(crate) use buffer::Buffer; +pub(crate) use frame::Frame; +pub(crate) use terminal::Terminal; diff --git a/sericom-core/src/ui/terminal.rs b/sericom-core/src/ui/terminal.rs new file mode 100644 index 0000000..1cb8a7b --- /dev/null +++ b/sericom-core/src/ui/terminal.rs @@ -0,0 +1,57 @@ +use crate::ui::{Buffer, Frame}; + +use crate::screen::{Rect, TermPos}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Terminal { + buffers: [Buffer; 2], + current_buffer: usize, + cursor_hidden: bool, + view_area: Rect, +} + +impl Terminal { + pub const fn get_frame(&mut self) -> Frame<'_> { + Frame { + buffer: &mut self.buffers[self.next_buf()], + cursor_position: None, + area: self.view_area, + } + } + + pub fn draw(&mut self, f: F) + where + F: FnOnce(&mut Frame), + { + // Clear next buffer before drawing into + self.buffers[self.next_buf()].reset(); + + let mut frame = self.get_frame(); + f(&mut frame); + } + + const fn next_buf(&self) -> usize { + 1 - self.current_buffer + } + + pub(crate) const fn area(&self) -> Rect { + self.view_area + } +} + +impl Default for Terminal { + fn default() -> Self { + use crossterm::terminal::size; + + let (term_w, term_y) = size().unwrap_or((80, 24)); + let view_area = Rect::from((term_w, term_y)); + let buffers = [Buffer::empty(view_area), Buffer::empty(view_area)]; + + Self { + buffers, + current_buffer: 0_usize, + cursor_hidden: false, + view_area, + } + } +} diff --git a/sericom/Cargo.toml b/sericom/Cargo.toml index 224301f..00e8672 100644 --- a/sericom/Cargo.toml +++ b/sericom/Cargo.toml @@ -28,13 +28,14 @@ eula = false rustflags = ["-C", "target-feature=+crt-static"] [dependencies] -clap = { version = "4.5.50", features = ["derive"] } +clap = { version = "4.5.53", features = ["derive", "help"] } +rustyline = { version = "17.0.1", features = ["derive"] } sericom-core = { version = "0.7.0", path = "../sericom-core" } tracing-appender = "0.2" -tracing-subscriber = "0.3.20" +tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } chrono.workspace = true tokio.workspace = true serial2-tokio.workspace = true -miette.workspace = true +miette = { workspace = true, features = ["fancy"] } crossterm.workspace = true tracing.workspace = true diff --git a/sericom/src/main.rs b/sericom/src/main.rs index e39003e..f3bd9c1 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -7,169 +7,37 @@ //! scripts is to be able to automate tasks that take place over a serial connection i.e. //! configuration, resetting, getting statistics, etc. -use clap::{CommandFactory, Parser, Subcommand}; -use crossterm::style::Stylize; -use miette::{Context, IntoDiagnostic}; -use sericom_core::{ - cli::{ - color_parser, get_settings, interactive_session, list_serial_ports, open_connection, - valid_baud_rate, - }, - configs::{get_config, initialize_config}, - path_utils::{is_script, validate_dir}, -}; -use std::{ - fmt::Display, - io::{self, Write}, - path::{Path, PathBuf}, -}; +use miette::{Context as _, IntoDiagnostic}; +use sericom_core::configs::{get_config, initialize_config}; +use std::path::Path; +use tracing_subscriber::filter::FilterExt; -#[derive(Parser)] -#[command(name = "sericom", version, about, long_about = None)] -#[command(propagate_version = true)] -struct Cli { - /// The path to a serial port. - /// - /// For Linux/MacOS something like `/dev/tty1`, Windows `COM1`. - port: Option, - /// Baud rate for the serial connection. - #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] - baud: u32, - #[clap(flatten)] - config_override: ConfigOverrides, - /// Path to a file for the output. - #[arg(short, long)] - file: Option>, - /// Display debug output - #[arg(short, long)] - debug: bool, - #[command(subcommand)] - command: Option, -} - -#[allow(clippy::enum_variant_names)] -#[derive(Subcommand)] -enum Commands { - /// Lists valid baud rates - Bauds, - /// Lists all available serial ports - Ports, - /// Gets the settings for a serial port - Settings { - #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] - baud: u32, - /// Path to the port to open - #[arg(short, long)] - port: String, - }, -} - -#[derive(Parser, Debug)] -struct ConfigOverrides { - /// Set the forground color for the text - #[arg(short, long, requires_all = &["port"], value_parser = color_parser)] - color: Option, - /// Override the `out-dir` for the file - /// - /// Alternatively could simply use the absolute path - #[arg(short, long, requires_all = &["port", "file"], value_parser = validate_dir)] - out_dir: Option, - /// Override the `exit-script` that's run after writing to a file - #[arg(long, requires_all = &["port", "file"], value_parser = is_script)] - exit_script: Option, -} - -impl From for sericom_core::configs::ConfigOverride { - fn from(overrides: ConfigOverrides) -> Self { - sericom_core::configs::ConfigOverride { - color: overrides.color, - out_dir: overrides.out_dir, - exit_script: overrides.exit_script, - } - } -} +mod repl; +use repl::*; #[tokio::main] async fn main() -> miette::Result<()> { - let cli = Cli::parse(); + initialize_config(None)?; + let _trace_guard: Option = { + let config = get_config().unwrap(); + let dbg_dir = config.defaults.debug_dir.as_path(); + init_tracing(dbg_dir)? + }; - if cli.port.is_none() && cli.command.is_none() { - let mut cmd = Cli::command(); - cmd.error( - clap::error::ErrorKind::MissingRequiredArgument, - "Missing either PORT or COMMAND.", - ) - .exit(); - } - - if cli.port.is_some() && cli.command.is_some() { - let mut cmd = Cli::command(); - cmd.error( - clap::error::ErrorKind::ArgumentConflict, - "Must specify either PORT or SUBCOMMAND, not both.", - ) - .exit(); - } - - if let Some(ref port) = cli.port { - let connection = open_connection(cli.baud, port)?; - let overrides: sericom_core::configs::ConfigOverride = cli.config_override.into(); - - if let Some(Some(path)) = &cli.file - && path.is_dir() - { - return Err(miette::miette!( - "Could not create file at: '{}' because it is a directory.", - path.display() - )); - } - initialize_config(overrides)?; - // Need to hold the guard in `main`'s scope - let _guard: Option = if let Some(ref port) = - cli.port - && cli.debug - { - let config = get_config(); - let out_dir = config.defaults.debug_dir.as_path(); - init_tracing(out_dir, port)? - } else { - None - }; - interactive_session(connection, cli.file, cli.debug, port).await?; - } else if let Some(cmd) = cli.command { - match cmd { - Commands::Bauds => { - let mut stdout = io::stdout(); - write!(stdout, "Valid baud rates:\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - for baud in serial2_tokio::COMMON_BAUD_RATES { - write!(stdout, "{baud}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - } - } - Commands::Ports => { - list_serial_ports()?; - } - Commands::Settings { baud, port } => { - get_settings(baud, &port)?; - } - } - } - Ok(()) + run_repl().await } -fn init_tracing( - out_dir: &Path, - port: S, -) -> miette::Result> -where - S: AsRef + Display + Into, -{ +fn init_tracing( + dbg_dir: &Path, +) -> miette::Result> { use sericom_core::compat_port_path; + use tracing::level_filters::LevelFilter; + use tracing_subscriber::EnvFilter; + use tracing_subscriber::layer::{Layer, SubscriberExt}; + use tracing_subscriber::util::SubscriberInitExt; + use tracing_subscriber::{filter, fmt}; - let path = compat_port_path!(out_dir, port, prefix = "trace"); + let path = compat_port_path!(trace, dbg_dir); let file = std::fs::File::options() .write(true) .create(true) @@ -177,16 +45,32 @@ where .open(&path) .into_diagnostic() .wrap_err_with(|| format!("Failed to create '{}'", path.display()))?; + let (non_blocking, guard) = tracing_appender::non_blocking(file); - let subscriber = tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) - .with_writer(non_blocking) - // .without_time() - .with_line_number(false) - .with_target(false) - .finish(); - tracing::subscriber::set_global_default(subscriber) - .into_diagnostic() - .wrap_err("Failed to set subscriber")?; + + let targets_filter = filter::Targets::new() + .with_target("sericom_core", tracing::Level::TRACE) + .with_target("sericom", tracing::Level::TRACE) + .with_default(tracing::Level::ERROR); + let env_filter = EnvFilter::builder() + .with_default_directive( + // #[cfg(debug_assertions)] + // LevelFilter::TRACE.into(), + // #[cfg(not(debug_assertions))] + LevelFilter::INFO.into(), + ) + .with_env_var("SERI_LOG") + .from_env_lossy(); + + tracing_subscriber::registry() + .with( + fmt::layer() + .with_writer(non_blocking) + .with_line_number(false) + .with_target(true), + ) + .with(targets_filter.and_then(env_filter)) + .init(); + Ok(Some(guard)) } diff --git a/sericom/src/repl.rs b/sericom/src/repl.rs new file mode 100644 index 0000000..9a0725b --- /dev/null +++ b/sericom/src/repl.rs @@ -0,0 +1,307 @@ +use clap::{CommandFactory, Parser, Subcommand}; +use miette::{Context as _, IntoDiagnostic}; +use sericom_core::{ + cli::{color_parser, list_serial_ports, valid_baud_rate}, + path_utils::{is_script, validate_dir}, +}; +use std::path::PathBuf; + +const DIM: &str = "\x1b\x5b2m"; +const RESET: &str = "\x1b\x5b0m"; +const PARSER_TEMPLATE: &str = "\ + {all-args}{after-help}\ +"; +const SUBCOMMAND_TEMPLATE: &str = "\ + {usage-heading}\n {usage}\n\ + \n\ + {all-args}{after-help}\ +"; + +#[derive(Parser)] +#[command( + version, + about, + long_about = None, + infer_subcommands = true, + arg_required_else_help = true, + help_template = PARSER_TEMPLATE, + disable_colored_help = false, + after_help = "For command specific help use `help [COMMAND]`.\n\ + To get help in the middle of a command you can use `?`.\n\ + For example: `list ?` will print the help text for the `list` command. + ", + multicall = true +)] +struct Repl { + #[command(subcommand)] + command: Commands, +} + +#[allow(clippy::enum_variant_names)] +#[derive(Subcommand)] +enum Commands { + /// Connect to a serial port + #[command( + visible_aliases = ["c", "con"], + long_about = None, + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] + Connect { + /// The path to a serial port + /// + /// For Linux/MacOS something like `/dev/ttyUSB0`, Windows `COM1`. + port: PathBuf, + /// Baud rate for the serial connection + #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] + baud: u32, + /// Path to a file to write the session's output + #[arg(short, long)] + file: Option>, + /// Start the session in the background (headless) + #[arg(long)] + bg: bool, + #[clap(flatten)] + config_override: ConfigOverrides, + }, + /// Close a session + #[command( + visible_alias = "k", + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] + Kill { + /// A space-delimited list of sessions to terminate + /// + /// For example: `kill 0 2 3` or `kill 0` + session: Vec, + }, + /// Write to a session + #[command( + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true, + )] + Send { + /// A space-delimited list of sessions to send the message to + /// + /// For example: `kill 0 2 3 hello world` or `send 0 hello world` + session: sericom_core::session::SessionID, + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + message: Vec, + }, + /// List helpful information + #[command( + visible_aliases = ["ls","l"], + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] + List { + #[command(subcommand)] + cmd: ListCmds, + }, + /// Exit the program + #[command(visible_aliases = ["e", "q","quit"])] + Exit, + /// Print the version of sericom + #[command(visible_short_flag_alias = 'V', visible_long_flag_alias = "version")] + Version, + // TODO: Use this to print user settings like `git config --list` + // Set { + // /* Stuff to set settings */ + // } +} + +#[derive(Subcommand)] +enum ListCmds { + /// List the current sessions/connections + #[command(visible_alias = "s")] + Sessions, + /// List available serial ports + #[command(visible_alias = "p")] + Ports, + /// List valid baud rates + #[command(visible_alias = "b")] + Bauds, + /// List the current configuration + #[command(visible_alias = "conf")] + Config, +} + +#[derive(Parser, Debug)] +struct ConfigOverrides { + /// Set the forground color for the text + #[arg(short, long, requires_all = &["port"], value_parser = color_parser)] + color: Option, + /// Override the `out-dir` for the file + /// + /// Alternatively could simply use the absolute path + #[arg(short, long, requires_all = &["port", "file"], value_parser = validate_dir)] + out_dir: Option, + /// Override the `script` that's run after writing to a file + #[arg(long, requires_all = &["port", "file"], value_parser = is_script)] + script: Option, +} + +impl From for sericom_core::configs::ConfigOverride { + fn from(overrides: ConfigOverrides) -> Self { + sericom_core::configs::ConfigOverride { + color: overrides.color, + out_dir: overrides.out_dir, + script: overrides.script, + } + } +} + +pub async fn run_repl() -> miette::Result<()> { + let conf = rustyline::Config::builder() + .history_ignore_space(true) + .completion_show_all_if_ambiguous(true) + .completion_type(rustyline::CompletionType::Circular) + .build(); + let mut rl = rustyline::DefaultEditor::with_config(conf) + .into_diagnostic() + .wrap_err("Failed to create REPL")?; + let history_path = std::env::home_dir() + .unwrap_or(PathBuf::from("./")) + .join(".sericom_history"); + + if rl.load_history(&history_path).is_err() { + println!("{DIM}No previous history{RESET}"); + } + println!("Welcome to the sericom, type 'exit' to quit."); + + let mut manager = sericom_core::session::SessionManager::new(); + loop { + let readline = rl.readline(">> "); + match readline { + Ok(line) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + + rl.add_history_entry(line).ok(); + + if line.contains('?') { + handle_help(line); + continue; + } + + match Repl::try_parse_from(line.split_whitespace().collect::>()) { + Ok(cli) => match handle_cmds(cli.command, &mut manager).await { + Ok(true) => break, + Ok(false) => { + manager + .check_errors(|errs| { + for (id, err) in errs { + println!("[session {id}]:\n{err:?}"); + } + }) + .await; + } + Err(e) => println!("{e:?}"), + }, + Err(e) => e.print().expect("error printing clap error"), + } + } + Err(rustyline::error::ReadlineError::Interrupted) => continue, + Err(err) => { + tracing::debug!(target: "repl", %err, "got unknown error from rustyline"); + break; + } + } + } + + rl.save_history(&history_path).ok(); + manager.graceful_shutdown().await; + Ok(()) +} + +async fn handle_cmds( + cmd: Commands, + manager: &mut sericom_core::session::SessionManager, +) -> miette::Result { + match cmd { + Commands::Connect { + port, + baud, + #[allow(unused)] + config_override, + file, + bg, + } => { + let res = match (file.as_ref(), bg) { + (Some(_), bg) => manager.spawn(port, baud, file, bg).map(|_| false), + (None, bg) => manager.spawn(port, baud, None, bg).map(|_| false), + }; + + // Allow initialization errors to propogate + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + manager + .check_errors(|errs| { + for (id, err) in errs { + println!("[session {id}]:\n{err:?}"); + } + }) + .await; + res + } + Commands::Kill { session } => { + for id in session { + manager.kill(id).await; + tracing::debug!("session {id} closed"); + } + Ok(false) + } + Commands::Send { session, message } => { + let msg = message.join(" "); + manager + .send(session, &msg) + .await + .map_err(miette::Report::from)?; + tracing::debug!(%session, %msg, "sent message"); + Ok(false) + } + Commands::List { cmd } => match cmd { + ListCmds::Ports => list_serial_ports() + .wrap_err("Failed to list available ports. Platform unsupported") + .map(|_| false), + ListCmds::Bauds => { + println!("Valid baud rates:"); + for baud in serial2_tokio::COMMON_BAUD_RATES { + println!("{baud}"); + } + Ok(false) + } + ListCmds::Sessions => { + manager.list(&mut std::io::stdout()); + Ok(false) + } + ListCmds::Config => todo!(), + }, + Commands::Exit => Ok(true), + Commands::Version => { + println!("{}", Repl::command().render_long_version()); + Ok(false) + } + } +} + +fn handle_help(line: &str) { + let mut cmd = Repl::command(); + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens == ["?"] || tokens.is_empty() { + cmd.print_help().expect("help won't err"); + return; + } + + if let Some((sub, rest)) = tokens.split_first() + && *rest == ["?"] + && let Some(mut sub_cmd) = cmd + .get_subcommands_mut() + .find(|s| s.get_name() == *sub) + .cloned() + { + sub_cmd.print_help().expect("help won't err"); + } +} diff --git a/test-sericom/Cargo.lock b/test-sericom/Cargo.lock new file mode 100644 index 0000000..dc68afb --- /dev/null +++ b/test-sericom/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "test-sericom" +version = "0.1.0" diff --git a/test-sericom/Cargo.toml b/test-sericom/Cargo.toml new file mode 100644 index 0000000..e3dc3dd --- /dev/null +++ b/test-sericom/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "test-sericom" +version = "0.1.0" +edition = "2024" + +[dependencies] +libc = "0.2.178" +tracing.workspace = true diff --git a/test-sericom/src/lib.rs b/test-sericom/src/lib.rs new file mode 100644 index 0000000..d7c57dc --- /dev/null +++ b/test-sericom/src/lib.rs @@ -0,0 +1,122 @@ +#[cfg(unix)] +pub use pts::get_pts_pair; + +#[cfg(unix)] +mod pts { + use std::{ffi::CStr, fs::File, os::fd::FromRawFd}; + + // Needed constants (from fcntl.h) + const O_RDWR: libc::c_int = libc::O_RDWR; + const O_NOCTTY: libc::c_int = libc::O_NOCTTY; + + /// # Example + /// ``` + /// let (mut master, slave) = get_pts_pair()?; + /// println!("Got slave: {slave}"); + /// + /// master.write_all(b"Hello from simulated serial!\r\n")?; + /// master.flush()?; + /// ``` + pub fn get_pts_pair() -> std::io::Result<(File, &'static str)> { + use std::io::Error; + + unsafe { + let master_fd: libc::c_int = libc::posix_openpt(O_RDWR | O_NOCTTY); + if master_fd < 0 { + let e = Error::last_os_error(); + tracing::error!("posix_openpt failed: {e}"); + return Err(e); + } + + if libc::grantpt(master_fd) != 0 { + let e = Error::last_os_error(); + tracing::error!("grantpt failed: {e}"); + libc::close(master_fd); + return Err(e); + } + if libc::unlockpt(master_fd) != 0 { + let e = Error::last_os_error(); + tracing::error!("unlockpt failed: {e}"); + libc::close(master_fd); + return Err(e); + } + + let pts_name = libc::ptsname(master_fd); + if pts_name.is_null() { + let e = Error::last_os_error(); + tracing::error!("pts_name failed - is null: {e}"); + libc::close(master_fd); + return Err(e); + } + + let slave_path = CStr::from_ptr(pts_name as *const libc::c_char) + .to_str() + .expect("Failed to convert pts_name to CStr"); + + set_baud_raw(master_fd).inspect_err(|_| { + libc::close(master_fd); + })?; + + let slave_fd = libc::open(pts_name, O_RDWR | O_NOCTTY); + if slave_fd < 0 { + let e = Error::last_os_error(); + tracing::error!("failed to open the slave: {e}"); + libc::close(master_fd); + return Err(e); + } + + set_baud_raw(slave_fd).inspect_err(|_| { + libc::close(slave_fd); + libc::close(master_fd); + })?; + libc::close(slave_fd); + + let master = std::fs::File::from_raw_fd(master_fd); + tracing::debug!(%slave_path); + Ok((master, slave_path)) + } + } + + fn set_baud_raw(fd: libc::c_int) -> std::io::Result<()> { + use std::io::Error; + + let mut tio: libc::termios = unsafe { + let mut tio: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(fd, &mut tio) != 0 { + let e = Error::last_os_error(); + tracing::error!("tcgetattr failed: {e}"); + return Err(e); + } + tio + }; + + // set input/output speeds + unsafe { + if libc::cfsetispeed(&mut tio, libc::B9600) != 0 + || libc::cfsetospeed(&mut tio, libc::B9600) != 0 + { + let e = Error::last_os_error(); + tracing::error!("failed to set input/output speeds: {e}"); + return Err(e); + } + } + + // a minimal “raw” setup: 8 N 1, no echo / canonical + tio.c_iflag = 0; + tio.c_oflag = 0; + tio.c_cflag = libc::CS8 | libc::CREAD | libc::CLOCAL; + tio.c_lflag = 0; + tio.c_cc[libc::VMIN] = 1; + tio.c_cc[libc::VTIME] = 0; + + unsafe { + if libc::tcsetattr(fd, libc::TCSANOW, &tio) != 0 { + let e = Error::last_os_error(); + tracing::error!("tcsetattr failed: {e}"); + return Err(e); + } + } + + Ok(()) + } +}