diff --git a/Cargo.lock b/Cargo.lock index edb38139..c0e991e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.1-dev" +source = "git+https://github.com/l0ng-ai/alacritty?rev=1276f128fbaa8832cbc66210675cbe8aeb570499#1276f128fbaa8832cbc66210675cbe8aeb570499" +dependencies = [ + "base64", + "bitflags 2.13.1", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.4", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -485,6 +509,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -686,14 +713,12 @@ dependencies = [ name = "chitin" version = "0.1.0" dependencies = [ - "chitin-bio", "chitin-command", "chitin-databases", "clap", "clap_complete", "console", "indicatif", - "serde_json", "thiserror 2.0.20", "tokio", ] @@ -724,9 +749,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "chitin-builtin-shell" +version = "0.1.0" +dependencies = [ + "chitin-command", + "chitin-databases", + "chitin-terminal", + "clap", + "thiserror 2.0.20", + "tokio", +] + [[package]] name = "chitin-command" version = "0.1.0" +dependencies = [ + "chitin-bio", + "chitin-databases", + "clap", + "serde_json", + "thiserror 2.0.20", + "tokio", +] [[package]] name = "chitin-databases" @@ -753,9 +798,11 @@ version = "0.1.0" dependencies = [ "bytemuck", "chitin-bio", + "chitin-builtin-shell", "chitin-command", "chitin-databases", "chitin-molecule-renderer", + "chitin-terminal", "chitin-ui", "chitin-utils", "chitin-wgpu", @@ -791,10 +838,21 @@ dependencies = [ "wgpu", ] +[[package]] +name = "chitin-terminal" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "async-channel 2.5.0", + "portable-pty", + "thiserror 2.0.20", +] + [[package]] name = "chitin-ui" version = "0.1.0" dependencies = [ + "chitin-terminal", "criterion", "env_logger", "gpui-ce", @@ -1706,6 +1764,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2765,6 +2834,15 @@ dependencies = [ "leaky-cow", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -3132,6 +3210,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3188,6 +3275,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3272,6 +3368,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.29.0" @@ -3954,6 +4064,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "postage" version = "0.5.0" @@ -4526,6 +4657,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.43" @@ -4839,6 +4981,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -4867,6 +5051,22 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shellexpand" version = "3.1.2" @@ -4888,6 +5088,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5391,6 +5601,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5714,7 +5933,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -5923,6 +6142,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cursor-icon", + "log", + "memchr", + "serde", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -6782,6 +7015,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index 14c2a669..75c86f1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/chitin-builtin-shell", "crates/chitin-command", "crates/chitin-cli", "crates/chitin-bio", @@ -8,6 +9,7 @@ members = [ "crates/chitin-databases", "crates/chitin-desktop", "crates/chitin-molecule-renderer", + "crates/chitin-terminal", "crates/chitin-ui", "crates/chitin-wgpu", "crates/chitin-wasm", diff --git a/assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf b/assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf new file mode 100644 index 00000000..d540de92 Binary files /dev/null and b/assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf differ diff --git a/assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf b/assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf new file mode 100644 index 00000000..e697d012 Binary files /dev/null and b/assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf differ diff --git a/assets/fonts/cascadia-mono/LICENSE b/assets/fonts/cascadia-mono/LICENSE new file mode 100644 index 00000000..b7f91076 --- /dev/null +++ b/assets/fonts/cascadia-mono/LICENSE @@ -0,0 +1,94 @@ +Copyright (c) 2019 - Present, Microsoft Corporation, +with Reserved Font Name Cascadia Code. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/icons/terminal-add.svg b/assets/icons/terminal-add.svg new file mode 100644 index 00000000..efde3add --- /dev/null +++ b/assets/icons/terminal-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/terminal-bash.svg b/assets/icons/terminal-bash.svg new file mode 100644 index 00000000..e2fb81e9 --- /dev/null +++ b/assets/icons/terminal-bash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-builtin.svg b/assets/icons/terminal-builtin.svg new file mode 100644 index 00000000..c3c68c71 --- /dev/null +++ b/assets/icons/terminal-builtin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-fish.svg b/assets/icons/terminal-fish.svg new file mode 100644 index 00000000..fb23983e --- /dev/null +++ b/assets/icons/terminal-fish.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-nushell.svg b/assets/icons/terminal-nushell.svg new file mode 100644 index 00000000..d0e1c188 --- /dev/null +++ b/assets/icons/terminal-nushell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-powershell.svg b/assets/icons/terminal-powershell.svg new file mode 100644 index 00000000..e95ae4f4 --- /dev/null +++ b/assets/icons/terminal-powershell.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/terminal-shell.svg b/assets/icons/terminal-shell.svg new file mode 100644 index 00000000..9c3aa98a --- /dev/null +++ b/assets/icons/terminal-shell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-trash.svg b/assets/icons/terminal-trash.svg new file mode 100644 index 00000000..75c749b6 --- /dev/null +++ b/assets/icons/terminal-trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/terminal-zsh.svg b/assets/icons/terminal-zsh.svg new file mode 100644 index 00000000..30817084 --- /dev/null +++ b/assets/icons/terminal-zsh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/chitin-builtin-shell/Cargo.toml b/crates/chitin-builtin-shell/Cargo.toml new file mode 100644 index 00000000..0af70fcd --- /dev/null +++ b/crates/chitin-builtin-shell/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "chitin-builtin-shell" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +chitin-command = { path = "../chitin-command" } +chitin-databases = { path = "../chitin-databases" } +chitin-terminal = { path = "../chitin-terminal" } +clap = { version = "4.6", features = ["derive"] } +thiserror = "2.0" + +[dev-dependencies] +tokio = { version = "1.52", features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/crates/chitin-builtin-shell/src/event.rs b/crates/chitin-builtin-shell/src/event.rs new file mode 100644 index 00000000..37fbbd17 --- /dev/null +++ b/crates/chitin-builtin-shell/src/event.rs @@ -0,0 +1,59 @@ +//! Observable events emitted by a built-in shell session. + +use std::sync::Arc; + +use chitin_command::CommandExecutionEvent; + +use crate::{ShellCommandId, ShellExecutionRecord, ShellExecutionStatus}; + +/// A state transition emitted by a built-in shell session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShellEvent { + /// A parsed command was accepted as the active submission. + Submitted(ShellExecutionRecord), + /// A portable executor emitted progress, a message, or an artifact. + CommandExecution { + /// Shell-local identity of the active submission. + id: ShellCommandId, + /// Frontend-neutral event emitted by the command executor. + event: CommandExecutionEvent, + }, + /// The active command entered a new shell lifecycle state. + StateChanged { + /// Shell-local identity of the affected submission. + id: ShellCommandId, + /// Current lifecycle state. + status: ShellExecutionStatus, + }, +} + +/// Cloneable callback receiving built-in shell events. +#[derive(Clone)] +pub struct ShellEventSink { + callback: Arc, +} + +impl ShellEventSink { + /// Creates a shell event sink from a thread-safe callback. + pub fn new(callback: impl Fn(ShellEvent) + Send + Sync + 'static) -> Self { + Self { + callback: Arc::new(callback), + } + } + + /// Emits one event to the shell host. + pub fn emit(&self, event: ShellEvent) { + (self.callback)(event); + } + + /// Creates a sink that deliberately discards every event. + pub fn silent() -> Self { + Self::new(|_| {}) + } +} + +impl Default for ShellEventSink { + fn default() -> Self { + Self::silent() + } +} diff --git a/crates/chitin-builtin-shell/src/grammar.rs b/crates/chitin-builtin-shell/src/grammar.rs new file mode 100644 index 00000000..1dd0af80 --- /dev/null +++ b/crates/chitin-builtin-shell/src/grammar.rs @@ -0,0 +1,523 @@ +//! Built-in shell grammar composed around shared portable command fragments. + +use std::ffi::OsString; + +use chitin_command::{ + CommandExecutionDomain, CommandId, CommandSpec, FrontendCommand, PortableCommand, command_spec_by_name, + command_specs, + grammar::{PortableCommandArgs, PortableCommandLineError}, +}; +use clap::{ColorChoice, CommandFactory, Parser, Subcommand, error::ErrorKind}; + +/// Result of parsing one built-in shell line. +#[derive(Debug)] +pub enum BuiltinCommandLine { + /// A typed command executable without desktop state. + Portable(PortableCommand), + /// A typed command requiring desktop application state. + Frontend(FrontendCommand), + /// A command implemented by the shell session itself. + ShellBuiltin(ShellBuiltin), + /// Help or version text that should be displayed without execution. + Display(String), +} + +/// Commands whose semantics belong to the built-in shell session. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellBuiltin { + /// Clear visible scrollback without changing command history. + Clear, +} + +/// Failure while tokenizing or parsing built-in shell text. +#[derive(Debug, thiserror::Error)] +pub enum CommandLineParseError { + /// No command was present after whitespace was removed. + #[error("command line is empty")] + Empty, + /// A quoted token reached the end of the input without a closing quote. + #[error("unterminated {quote} quote starting at byte {position}")] + UnterminatedQuote { + /// Quote character that was not closed. + quote: char, + /// Byte offset of the opening quote. + position: usize, + }, + /// Clap rejected the composed built-in shell grammar. + #[error("{0}")] + Grammar(String), + /// A frontend grammar branch has no argument-free command to invoke. + /// + /// This is unreachable while every frontend branch stays argument-free; it + /// exists so the conversion reports a typed failure instead of panicking. + #[error("command '{command}' has no argument-free frontend form")] + MissingFrontendCommand { + /// Stable identity of the frontend command without an invocation form. + command: CommandId, + }, + /// A shared command-line value failed domain validation. + #[error(transparent)] + Portable(#[from] PortableCommandLineError), +} + +/// Complete grammar available inside Chitin's built-in shell. +#[derive(Debug, Parser)] +#[command( + name = "chitin", + about = "Chitin structural biology tools", + color = ColorChoice::Never, + arg_required_else_help = true +)] +pub struct BuiltinShellGrammar { + #[command(subcommand)] + command: BuiltinShellCommandArgs, +} + +/// Top-level commands composed specifically for the built-in shell. +#[derive(Debug, Subcommand)] +enum BuiltinShellCommandArgs { + /// Execute a command shared with non-GUI frontends. + #[command(flatten)] + Portable(PortableCommandArgs), + /// Clear visible terminal scrollback. + Clear, +} + +impl BuiltinShellCommandArgs { + /// Converts the selected grammar branch into its execution-domain result. + fn into_line(self) -> Result { + let line = match self { + Self::Portable(arguments) => BuiltinCommandLine::Portable(arguments.into_command()?), + Self::Clear => BuiltinCommandLine::ShellBuiltin(ShellBuiltin::Clear), + }; + Ok(line) + } +} + +/// Resolves a stable identity into the argument-free frontend command to run. +/// +/// # Parameters +/// +/// * `id` is the stable identity named by a built-in shell grammar branch. +/// +/// # Returns +/// +/// The frontend command registered for that identity. +/// +/// # Errors +/// +/// Returns [`CommandLineParseError::MissingFrontendCommand`] when the identity +/// carries required arguments and therefore cannot be invoked from a grammar +/// branch that accepts none. +fn frontend_command(id: CommandId) -> Result { + id.frontend_command_without_arguments() + .ok_or(CommandLineParseError::MissingFrontendCommand { command: id }) +} + +/// Parses one built-in shell line using the composed shell grammar. +/// +/// # Parameters +/// +/// * `input` is one complete line without a trailing newline. +/// +/// # Returns +/// +/// A portable command, frontend command, shell builtin, or display-only help. +/// +/// # Errors +/// +/// Returns [`CommandLineParseError`] when tokenization, Clap validation, or +/// domain conversion fails. +pub fn parse_builtin_command_line(input: &str) -> Result { + let tokens = tokenize(input)?; + if tokens.is_empty() { + return Err(CommandLineParseError::Empty); + } + if let Some(line) = parse_registered_frontend_command(&tokens)? { + return Ok(line); + } + let include_frontend_help = tokens.len() == 1 && matches!(tokens[0].as_str(), "help" | "-h" | "--help"); + parse_grammar(tokens, include_frontend_help) +} + +/// Generates full-line completion candidates from the built-in shell grammar. +/// +/// # Parameters +/// +/// * `input` is the current editable command line up to the caret. +/// +/// # Returns +/// +/// Complete replacement lines whose current command or option starts with the +/// unfinished token. Invalid or quoted prefixes return no candidates. +pub fn complete_builtin_shell_line(input: &str) -> Vec { + let (head, prefix) = completion_prefix(input); + let Ok(tokens) = tokenize(head) else { + return Vec::new(); + }; + let mut root = BuiltinShellGrammar::command(); + root.build(); + let mut command = &root; + for token in &tokens { + if token.starts_with('-') { + continue; + } + if let Some(subcommand) = command.get_subcommands().find(|subcommand| { + subcommand.get_name() == token || subcommand.get_all_aliases().any(|alias| alias == token.as_str()) + }) { + command = subcommand; + } + } + + if tokens.len() == 1 && command_spec_by_name(&tokens[0]).is_some() { + return Vec::new(); + } + + let mut candidates = command + .get_subcommands() + .map(|subcommand| subcommand.get_name().to_owned()) + .chain( + command + .get_arguments() + .filter_map(|argument| argument.get_long().map(|long| format!("--{long}"))), + ) + .chain( + command + .get_arguments() + .filter_map(|argument| argument.get_short().map(|short| format!("-{short}"))), + ) + .chain( + tokens + .is_empty() + .then(frontend_specs) + .into_iter() + .flatten() + .map(|spec| spec.name.to_owned()), + ) + .filter(|candidate| candidate.starts_with(prefix)) + .map(|candidate| format!("{head}{candidate}")) + .collect::>(); + candidates.sort(); + candidates.dedup(); + candidates +} + +/// Separates completed input from the token currently being completed. +fn completion_prefix(input: &str) -> (&str, &str) { + input + .char_indices() + .rfind(|(_, character)| character.is_whitespace()) + .map_or(("", input), |(index, character)| { + let next = index + character.len_utf8(); + (&input[..next], &input[next..]) + }) +} + +/// Parses tokenized syntax through Clap without terminating the host process. +fn parse_grammar( + tokens: Vec, + include_frontend_help: bool, +) -> Result { + let arguments = std::iter::once(OsString::from("chitin")).chain(tokens.into_iter().map(OsString::from)); + match BuiltinShellGrammar::try_parse_from(arguments) { + Ok(parsed) => parsed.command.into_line(), + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ) => + { + let mut help = error.to_string(); + if include_frontend_help { + append_frontend_help(&mut help); + } + Ok(BuiltinCommandLine::Display(help)) + } + Err(error) => Err(CommandLineParseError::Grammar(normalize_clap_error(error.to_string()))), + } +} + +/// Parses registry-driven frontend names before delegating portable syntax to Clap. +/// +/// # Parameters +/// +/// * `tokens` is the tokenized built-in shell input. +/// +/// # Returns +/// +/// A frontend command or its help text when the first token is a registered +/// frontend name; otherwise `None` so portable and shell grammar can continue. +/// +/// # Errors +/// +/// Returns [`CommandLineParseError`] when a frontend command receives arguments +/// or its catalog entry cannot construct its argument-free command value. +fn parse_registered_frontend_command(tokens: &[String]) -> Result, CommandLineParseError> { + let (name, help_requested) = match tokens { + [help, name] if help == "help" => (name.as_str(), true), + [name, flag] if flag == "-h" || flag == "--help" => (name.as_str(), true), + [name, ..] => (name.as_str(), false), + [] => return Ok(None), + }; + let Some(spec) = command_spec_by_name(name) else { + return Ok(None); + }; + if spec.execution_domain != CommandExecutionDomain::Frontend { + return Ok(None); + } + if help_requested { + return Ok(Some(BuiltinCommandLine::Display(frontend_help(spec)))); + } + if tokens.len() != 1 { + return Err(CommandLineParseError::Grammar(format!( + "unexpected argument '{}'\n\nUsage: chitin {}", + tokens[1], spec.name + ))); + } + Ok(Some(BuiltinCommandLine::Frontend(frontend_command(spec.id)?))) +} + +/// Iterates over argument-free frontend specifications exposed by the shell. +fn frontend_specs() -> impl Iterator { + command_specs().filter(|spec| spec.execution_domain == CommandExecutionDomain::Frontend && !spec.requires_arguments) +} + +/// Formats help for one registry-driven frontend command. +fn frontend_help(spec: &CommandSpec) -> String { + format!("{}\n\nUsage: chitin {}\n", spec.title, spec.name) +} + +/// Appends registry-driven frontend commands to Clap's root help. +fn append_frontend_help(help: &mut String) { + help.push_str("\nFrontend commands:\n"); + for spec in frontend_specs() { + help.push_str(&format!(" {:<42} {}\n", spec.name, spec.title)); + } +} + +/// Splits command text while preserving quoted whitespace and escapes. +fn tokenize(input: &str) -> Result, CommandLineParseError> { + let mut tokens = Vec::new(); + let mut token = String::new(); + let mut token_started = false; + let mut quote = None; + let mut characters = input.char_indices().peekable(); + + while let Some((position, character)) = characters.next() { + match quote { + Some((active, _)) if character == active => quote = None, + Some(('\'', _)) => { + token.push(character); + token_started = true; + } + Some(_) | None if character == '\\' => { + // Preserve path separators unless the next character is actually escapable. + let escaped = characters.next_if(|(_, next)| match quote { + Some(('"', _)) => matches!(next, '"' | '\\'), + None => matches!(next, '\'' | '"' | '\\') || next.is_whitespace(), + Some(_) => false, + }); + token.push(escaped.map_or('\\', |(_, character)| character)); + token_started = true; + } + None if character == '\'' || character == '"' => { + quote = Some((character, position)); + token_started = true; + } + None if character.is_whitespace() => { + if token_started { + tokens.push(std::mem::take(&mut token)); + token_started = false; + } + } + Some(_) | None => { + token.push(character); + token_started = true; + } + } + } + + if let Some((quote, position)) = quote { + return Err(CommandLineParseError::UnterminatedQuote { quote, position }); + } + if token_started { + tokens.push(token); + } + Ok(tokens) +} + +/// Removes Clap's own prefix because terminal presenters supply one. +fn normalize_clap_error(message: String) -> String { + message + .trim() + .strip_prefix("error: ") + .unwrap_or(message.trim()) + .to_owned() +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use chitin_command::{CommandId, DatabaseCommand, RcsbDownloadArguments, StructureCommand}; + use chitin_databases::providers::rcsb::StructureFormat; + + use super::*; + + #[test] + fn database_download_should_use_cli_defaults_and_short_options() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("db rcsb download --id 4hhb -o 'saved structure'")?; + + assert!(matches!( + parsed, + BuiltinCommandLine::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( + RcsbDownloadArguments { + format: StructureFormat::Pdb, + output: Some(output), + .. + } + ))) if output == Path::new("saved structure") + )); + Ok(()) + } + + #[test] + fn structure_validate_should_preserve_unquoted_windows_path() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line(r"structure validate C:\data\x.pdb")?; + + assert!(matches!( + parsed, + BuiltinCommandLine::Portable(PortableCommand::Structure(StructureCommand::Validate(arguments))) + if arguments.input.input == Path::new(r"C:\data\x.pdb") + )); + Ok(()) + } + + #[test] + fn database_download_should_preserve_quoted_windows_output_path() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line(r#"db rcsb download --id 4hhb -o "C:\out""#)?; + + assert!(matches!( + parsed, + BuiltinCommandLine::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( + RcsbDownloadArguments { output: Some(output), .. } + ))) if output == Path::new(r"C:\out") + )); + Ok(()) + } + + #[test] + fn tokenize_should_preserve_trailing_unquoted_backslash() -> Result<(), CommandLineParseError> { + assert_eq!( + tokenize("structure validate C:\\")?, + vec!["structure", "validate", "C:\\"] + ); + Ok(()) + } + + #[test] + fn tokenize_should_still_escape_unquoted_whitespace() -> Result<(), CommandLineParseError> { + assert_eq!( + tokenize(r"structure validate C:\my\ file.pdb")?, + vec!["structure", "validate", r"C:\my file.pdb"] + ); + Ok(()) + } + + #[test] + fn tokenize_should_still_escape_double_quote_inside_double_quotes() -> Result<(), CommandLineParseError> { + assert_eq!(tokenize(r#""a\"b""#)?, vec!["a\"b"]); + Ok(()) + } + + #[test] + fn missing_database_subcommand_should_return_clap_help() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("db")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) if help.contains("Usage: chitin db "))); + Ok(()) + } + + #[test] + fn stable_frontend_identifier_should_still_parse() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("tab.close")?; + + assert!(matches!(parsed, BuiltinCommandLine::Frontend(command) if command.id() == CommandId::PanelTabClose)); + Ok(()) + } + + #[test] + fn clear_should_parse_as_a_shell_builtin() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("clear")?; + + assert!(matches!(parsed, BuiltinCommandLine::ShellBuiltin(ShellBuiltin::Clear))); + Ok(()) + } + + #[test] + fn root_help_should_include_all_execution_domains() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("help")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) + if help.contains("db") && help.contains("tab.close") && help.contains("clear"))); + Ok(()) + } + + #[test] + fn completion_should_follow_the_composed_shell_grammar() { + assert_eq!(complete_builtin_shell_line("db r"), vec!["db rcsb"]); + assert!(complete_builtin_shell_line("tab.").contains(&"tab.close".to_owned())); + assert!(complete_builtin_shell_line("cl").contains(&"clear".to_owned())); + } + + #[test] + fn completion_should_list_every_option_of_a_nested_subcommand() { + // Clap contributes its own `--help` flag, so it is listed alongside the + // options declared by the shared portable fragment. + assert_eq!( + complete_builtin_shell_line("db rcsb download --"), + vec![ + "db rcsb download --format", + "db rcsb download --help", + "db rcsb download --id", + "db rcsb download --output", + ] + ); + } + + #[test] + fn subcommand_help_should_render_from_the_builtin_grammar() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("help structure")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) + if help.contains("inspect") && help.contains("validate"))); + Ok(()) + } + + #[test] + fn nested_subcommand_help_should_render_from_the_builtin_grammar() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("help db rcsb download")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) + if help.contains("--id") && help.contains("--format") && help.contains("--output"))); + Ok(()) + } + + #[test] + fn frontend_catalog_names_should_round_trip_through_the_shell() -> Result<(), CommandLineParseError> { + for spec in frontend_specs() { + let parsed = parse_builtin_command_line(spec.name)?; + assert!(matches!(parsed, BuiltinCommandLine::Frontend(command) if command.id() == spec.id)); + } + Ok(()) + } + + #[test] + fn frontend_help_should_come_from_the_canonical_spec() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("help tab.close")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) + if help.contains("Close Active Tab") && help.contains("Usage: chitin tab.close"))); + Ok(()) + } +} diff --git a/crates/chitin-builtin-shell/src/lib.rs b/crates/chitin-builtin-shell/src/lib.rs new file mode 100644 index 00000000..58466cfe --- /dev/null +++ b/crates/chitin-builtin-shell/src/lib.rs @@ -0,0 +1,24 @@ +#![forbid(unsafe_code)] +//! Session state and execution routing for Chitin's built-in command shell. +//! +//! This crate is independent of GPUI. It parses one command line at a time, +//! adapts terminal byte streams, retains navigation and execution history, forwards +//! portable commands to `chitin-command::execution`, and returns frontend commands +//! to the host application for execution. + +mod event; +mod grammar; +mod session; +mod terminal; + +pub use event::{ShellEvent, ShellEventSink}; +pub use grammar::{ + BuiltinCommandLine, BuiltinShellGrammar, CommandLineParseError, ShellBuiltin, complete_builtin_shell_line, + parse_builtin_command_line, +}; +pub use session::{ + BuiltinShell, BuiltinShellError, BuiltinShellSnapshot, ShellActiveCommand, ShellBuiltinEffect, ShellCommandId, + ShellCommandTarget, ShellExecutionRecord, ShellExecutionResult, ShellExecutionStatus, ShellInvocationSource, + ShellLineSubmission, ShellSubmission, ShellTranscriptContent, ShellTranscriptEntry, +}; +pub use terminal::{BuiltinTerminalEvent, BuiltinTerminalProgram}; diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs new file mode 100644 index 00000000..46564bd0 --- /dev/null +++ b/crates/chitin-builtin-shell/src/session.rs @@ -0,0 +1,1059 @@ +//! Built-in shell session state and command routing. + +use std::{ + path::PathBuf, + sync::{Arc, Mutex, MutexGuard}, +}; + +use crate::grammar::{BuiltinCommandLine, ShellBuiltin, parse_builtin_command_line}; +use chitin_command::{ + ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionDomain, CommandExecutionEvent, CommandId, + CommandMessage, CommandProgress, +}; +use chitin_command::{CommandExecutionError, CommandExecutor, CommandOutcome}; +use chitin_databases::{CancellationToken, PersistedArtifact}; + +use crate::{ShellEvent, ShellEventSink}; + +/// Monotonic identity assigned to one shell submission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ShellCommandId(u64); + +impl ShellCommandId { + /// Returns the numeric identity used inside this shell session. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Execution boundary selected for a parsed shell command. +pub type ShellCommandTarget = CommandExecutionDomain; + +/// Origin of a command invocation crossing the built-in shell bridge. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShellInvocationSource { + /// A person entered text in the built-in terminal. + Interactive, + /// An application agent submitted a structured command. + Agent { + /// Stable agent identity used for attribution and diagnostics. + name: String, + }, + /// Internal application automation submitted the command. + System { + /// Stable subsystem identity used for attribution and diagnostics. + name: String, + }, +} + +/// Lifecycle state retained for a submitted shell command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellExecutionStatus { + /// The command has been accepted and is executing. + Running, + /// Cancellation was requested and execution is winding down. + Cancelling, + /// Execution completed successfully. + Succeeded, + /// Execution returned an error. + Failed, + /// Execution stopped after cooperative cancellation. + Cancelled, +} + +impl ShellExecutionStatus { + /// Returns whether no further execution events are expected. + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) + } +} + +/// Persistent history for one accepted shell command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellExecutionRecord { + /// Shell-local submission identity. + pub id: ShellCommandId, + /// Original command text entered by the user. + pub input: String, + /// Typed request retained for audit, replay, and agent/kernel correlation. + pub command: ChitinCommand, + /// Stable command identity resolved by the parser. + pub command_id: CommandId, + /// Actor or subsystem that submitted the command. + pub source: ShellInvocationSource, + /// Executor boundary selected for the command. + pub target: ShellCommandTarget, + /// Current lifecycle state. + pub status: ShellExecutionStatus, +} + +/// Active command information exposed in a shell snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellActiveCommand { + /// Shell-local submission identity. + pub id: ShellCommandId, + /// Stable identity of the parsed command. + pub command_id: CommandId, + /// Executor boundary selected for the command. + pub target: ShellCommandTarget, + /// Current non-terminal lifecycle state. + pub status: ShellExecutionStatus, +} + +/// Content retained in the built-in shell scrollback model. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShellTranscriptContent { + /// Original input shown beside the shell prompt. + Input(String), + /// Most recent progress for a portable command. + Progress(CommandProgress), + /// Structured informational or warning message. + Message(CommandMessage), + /// Artifact persisted by a command. + Artifact(Box), + /// Successful terminal marker. + Completed, + /// Failed terminal marker and its user-facing message. + Failed(String), + /// Cooperative cancellation marker. + Cancelled, +} + +/// One ordered item in shell scrollback. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellTranscriptEntry { + /// Submission that produced this content. + pub command_id: ShellCommandId, + /// Render-neutral content interpreted by the eventual terminal view. + pub content: ShellTranscriptContent, +} + +/// Read-only copy of session state suitable for presentation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuiltinShellSnapshot { + /// Directory used to resolve relative command paths. + pub working_directory: PathBuf, + /// Currently executing command, when present. + pub active: Option, + /// Accepted commands in submission order. + pub executions: Vec, + /// Ordered render-neutral shell scrollback. + pub transcript: Vec, +} + +/// Parsed command and per-invocation resources prepared by a shell session. +#[derive(Clone, Debug)] +pub struct ShellSubmission { + id: ShellCommandId, + command: ChitinCommand, + target: ShellCommandTarget, + source: ShellInvocationSource, + context: CommandExecutionContext, +} + +/// Result of evaluating one textual built-in shell line. +#[derive(Clone, Debug)] +pub enum ShellLineSubmission { + /// A typed command was reserved for execution. + Command(ShellSubmission), + /// The shell session applied a built-in command synchronously. + ShellBuiltin(ShellBuiltinEffect), + /// Help text should be displayed without invoking an executor. + Display(String), +} + +/// Presentation effect produced by a command owned by the shell session. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellBuiltinEffect { + /// Remove visible command blocks while preserving navigable history. + ClearScrollback, +} + +impl ShellLineSubmission { + /// Extracts an executable submission from a command-producing line. + pub fn into_command(self) -> Result { + match self { + Self::Command(submission) => Ok(submission), + Self::Display(output) => Err(BuiltinShellError::DisplayOnly { output }), + Self::ShellBuiltin(effect) => Err(BuiltinShellError::BuiltinOnly { effect }), + } + } +} + +impl ShellSubmission { + /// Returns the shell-local submission identity. + pub const fn id(&self) -> ShellCommandId { + self.id + } + + /// Returns the parsed typed command. + pub const fn command(&self) -> &ChitinCommand { + &self.command + } + + /// Returns the executor boundary selected for this command. + pub const fn target(&self) -> ShellCommandTarget { + self.target + } + + /// Returns the actor or subsystem that initiated this command. + pub const fn source(&self) -> &ShellInvocationSource { + &self.source + } + + /// Returns the resources prepared for this invocation. + pub const fn context(&self) -> &CommandExecutionContext { + &self.context + } +} + +/// Correlated structured result returned by the portable command kernel. +#[derive(Clone, Debug, PartialEq)] +pub struct ShellExecutionResult { + /// Shell-local identity shared with the originating submission and events. + pub id: ShellCommandId, + /// Domain result returned without terminal-specific string conversion. + pub outcome: CommandOutcome, +} + +/// Failure while parsing, routing, or updating a built-in shell command. +#[derive(Debug, thiserror::Error)] +pub enum BuiltinShellError { + /// The submitted command line is syntactically or semantically invalid. + #[error(transparent)] + Parse(#[from] crate::grammar::CommandLineParseError), + /// A caller requiring execution received display-only help text. + #[error("shell line produced display output instead of an executable command")] + DisplayOnly { + /// Help text produced by the command-line grammar. + output: String, + }, + /// A caller requiring execution received a synchronous shell builtin. + #[error("shell line produced a built-in effect instead of an executable command")] + BuiltinOnly { + /// Effect already applied to the shell session. + effect: ShellBuiltinEffect, + }, + /// Another foreground command is still active. + #[error("shell command {} is still active", active_id.get())] + Busy { + /// Identity of the active command preventing submission. + active_id: ShellCommandId, + }, + /// A portable executor was requested for a frontend command. + #[error("shell command {} requires the frontend executor", id.get())] + FrontendCommand { + /// Identity of the incorrectly routed submission. + id: ShellCommandId, + }, + /// A completion or failure referred to a command other than the active one. + #[error("shell command {} is not the active command", id.get())] + InactiveCommand { + /// Identity supplied by the caller. + id: ShellCommandId, + }, + /// The shared command executor rejected or failed the command. + #[error(transparent)] + Execution(#[from] CommandExecutionError), + /// Session state could not be accessed after a panic poisoned its mutex. + #[error("built-in shell session state is unavailable")] + StateUnavailable, +} + +#[derive(Debug)] +struct ActiveCommand { + id: ShellCommandId, + command_id: CommandId, + target: ShellCommandTarget, + status: ShellExecutionStatus, + cancellation: CancellationToken, +} + +#[derive(Debug)] +struct ShellState { + context: CommandExecutionContext, + next_id: u64, + active: Option, + history: Vec, + history_cursor: Option, + navigation_draft: String, + executions: Vec, + transcript: Vec, +} + +/// Thread-safe session for Chitin's built-in command language. +/// +/// A session accepts one foreground command at a time. Portable commands are +/// executed through [`CommandExecutor`], while frontend commands are returned +/// to the host and must be finalized with [`BuiltinShell::complete_frontend`] +/// or [`BuiltinShell::fail_frontend`]. +#[derive(Clone)] +pub struct BuiltinShell { + state: Arc>, + events: ShellEventSink, +} + +impl BuiltinShell { + /// Creates a session rooted at the supplied working directory. + pub fn new(working_directory: impl Into) -> Self { + Self::from_context(CommandExecutionContext::new(working_directory)) + } + + /// Creates a session that reports lifecycle changes to an event sink. + pub fn with_event_sink(working_directory: impl Into, events: ShellEventSink) -> Self { + Self::from_context_and_event_sink(CommandExecutionContext::new(working_directory), events) + } + + /// Creates a session from frontend-supplied execution defaults. + pub fn from_context(context: CommandExecutionContext) -> Self { + Self::from_context_and_event_sink(context, ShellEventSink::silent()) + } + + /// Creates a configured session that reports lifecycle changes to an event sink. + pub fn from_context_and_event_sink(context: CommandExecutionContext, events: ShellEventSink) -> Self { + Self { + state: Arc::new(Mutex::new(ShellState { + context, + next_id: 1, + active: None, + history: Vec::new(), + history_cursor: None, + navigation_draft: String::new(), + executions: Vec::new(), + transcript: Vec::new(), + })), + events, + } + } + + /// Sets the workspace root supplied to subsequent commands. + pub fn set_workspace_root(&self, workspace_root: Option) -> Result<(), BuiltinShellError> { + self.lock_state()?.context.workspace_root = workspace_root; + Ok(()) + } + + /// Sets the default download root supplied to subsequent commands. + pub fn set_default_download_root(&self, root: Option) -> Result<(), BuiltinShellError> { + self.lock_state()?.context.default_download_root = root; + Ok(()) + } + + /// Sets standard-input bytes supplied to the next command submission. + pub fn set_standard_input(&self, bytes: Option>) -> Result<(), BuiltinShellError> { + self.lock_state()?.context.standard_input = bytes; + Ok(()) + } + + /// Parses a command line and reserves it as the active foreground command. + /// + /// # Parameters + /// + /// * `input` is one complete built-in shell command line. + /// + /// # Returns + /// + /// A typed submission carrying its execution target and isolated cancellation + /// token. + /// + /// # Errors + /// + /// Returns [`BuiltinShellError::Busy`] while another command is active, or a + /// parse error when the command line is invalid. + pub fn submit(&self, input: impl Into) -> Result { + self.submit_line(input, ShellInvocationSource::Interactive) + } + + /// Submits a command line attributed to a specific invocation source. + /// + /// # Parameters + /// + /// * `input` is the command text used for history and human-readable audit. + /// * `source` identifies the interactive, agent, or system caller. + /// + /// # Returns + /// + /// A parsed typed submission correlated with its shell-local identity. + pub fn submit_line( + &self, + input: impl Into, + source: ShellInvocationSource, + ) -> Result { + let input = input.into(); + let parsed = match parse_builtin_command_line(&input) { + Ok(parsed) => parsed, + Err(error) => { + self.remember_unsubmitted_input(input.clone())?; + return Err(error.into()); + } + }; + match parsed { + BuiltinCommandLine::Portable(command) => self + .submit_typed(input, command.into(), source) + .map(ShellLineSubmission::Command), + BuiltinCommandLine::Frontend(command) => self + .submit_typed(input, command.into(), source) + .map(ShellLineSubmission::Command), + BuiltinCommandLine::ShellBuiltin(command) => self + .execute_builtin(input, command) + .map(ShellLineSubmission::ShellBuiltin), + BuiltinCommandLine::Display(output) => { + self.remember_unsubmitted_input(input)?; + Ok(ShellLineSubmission::Display(output)) + } + } + } + + /// Applies a command whose state and lifecycle belong to the shell session. + /// + /// # Parameters + /// + /// * `input` is retained in navigable history after successful execution. + /// * `command` identifies the synchronous session mutation to apply. + /// + /// # Returns + /// + /// A presentation effect for the host terminal to mirror. + /// + /// # Errors + /// + /// Returns [`BuiltinShellError::Busy`] while a foreground command is active, + /// or [`BuiltinShellError::StateUnavailable`] when session state is poisoned. + fn execute_builtin(&self, input: String, command: ShellBuiltin) -> Result { + let mut state = self.lock_state()?; + if let Some(active) = state.active.as_ref() { + return Err(BuiltinShellError::Busy { active_id: active.id }); + } + state.history.push(input); + state.history_cursor = None; + state.navigation_draft.clear(); + match command { + ShellBuiltin::Clear => { + state.transcript.clear(); + Ok(ShellBuiltinEffect::ClearScrollback) + } + } + } + + /// Submits an already typed command from an agent or application subsystem. + /// + /// This is the primary non-interactive bridge API. It prevents agents from + /// serializing validated arguments back into shell text merely to invoke a + /// computation kernel. + /// + /// # Parameters + /// + /// * `input` is a human-readable audit representation of the request. + /// * `command` contains the validated command identity and arguments. + /// * `source` identifies the agent or subsystem initiating the request. + /// + /// # Returns + /// + /// A correlated submission ready for the portable or frontend executor. + pub fn submit_typed( + &self, + input: impl Into, + command: ChitinCommand, + source: ShellInvocationSource, + ) -> Result { + let input = input.into(); + let target = command.execution_domain(); + let cancellation = CancellationToken::new(); + let (submission, record) = { + let mut state = self.lock_state()?; + if let Some(active) = state.active.as_ref() { + return Err(BuiltinShellError::Busy { active_id: active.id }); + } + + let id = ShellCommandId(state.next_id); + state.next_id = state.next_id.saturating_add(1); + let command_id = command.id(); + let context = state.context.clone().with_cancellation(cancellation.clone()); + let record = ShellExecutionRecord { + id, + input: input.clone(), + command: command.clone(), + command_id, + source: source.clone(), + target, + status: ShellExecutionStatus::Running, + }; + state.history.push(input.clone()); + state.history_cursor = None; + state.navigation_draft.clear(); + state.executions.push(record.clone()); + state.transcript.push(ShellTranscriptEntry { + command_id: id, + content: ShellTranscriptContent::Input(input), + }); + state.active = Some(ActiveCommand { + id, + command_id, + target, + status: ShellExecutionStatus::Running, + cancellation, + }); + ( + ShellSubmission { + id, + command, + target, + source, + context, + }, + record, + ) + }; + self.events.emit(ShellEvent::Submitted(record)); + Ok(submission) + } + + /// Executes a portable submission and records its events and terminal state. + /// + /// # Parameters + /// + /// * `submission` is the active value returned by [`BuiltinShell::submit`]. + /// * `executor` runs database and structure commands without frontend state. + /// + /// # Returns + /// + /// The typed portable-command outcome for presentation by the shell host. + /// + /// # Errors + /// + /// Returns an error when the submission is stale, requires the frontend, or + /// fails in the shared command executor. Execution failures are retained in + /// the transcript before being returned. + pub async fn execute_portable( + &self, + submission: ShellSubmission, + executor: &CommandExecutor, + ) -> Result { + self + .execute_portable_with_events(submission, executor, CommandEventSink::silent()) + .await + } + + /// Executes a portable submission while forwarding command events to a host. + /// + /// # Parameters + /// + /// * `submission` is the active value returned by [`BuiltinShell::submit`]. + /// * `executor` runs database and structure commands without frontend state. + /// * `host_events` receives the same structured events recorded by the shell. + /// + /// # Returns + /// + /// The correlated typed outcome produced by the portable command kernel. + /// + /// # Errors + /// + /// Returns an error when routing is invalid or command execution fails. The + /// shell records the terminal state before returning an execution failure. + pub async fn execute_portable_with_events( + &self, + submission: ShellSubmission, + executor: &CommandExecutor, + host_events: CommandEventSink, + ) -> Result { + let id = submission.id; + let events = self.portable_event_sink(&submission, host_events)?; + let ChitinCommand::Portable(command) = submission.command else { + return Err(BuiltinShellError::FrontendCommand { id: submission.id }); + }; + match executor.execute(command, submission.context, events).await { + Ok(outcome) => { + self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed)?; + Ok(ShellExecutionResult { id, outcome }) + } + Err(error) => { + let cancelled = self.active_is_cancelled(id)?; + let status = if cancelled { + ShellExecutionStatus::Cancelled + } else { + ShellExecutionStatus::Failed + }; + let content = if cancelled { + ShellTranscriptContent::Cancelled + } else { + ShellTranscriptContent::Failed(error.to_string()) + }; + self.finish(id, status, content)?; + Err(BuiltinShellError::Execution(error)) + } + } + } + + /// Creates an event sink that records portable executor events in this session. + /// + /// # Parameters + /// + /// * `submission` identifies the active portable command receiving events. + /// * `host_events` receives a copy for task-center or protocol projection. + /// + /// # Returns + /// + /// A sink that updates the shell transcript before forwarding each event. + /// + /// # Errors + /// + /// Returns [`BuiltinShellError`] when the submission is no longer active or + /// targets the desktop frontend instead of the portable executor. + pub fn portable_event_sink( + &self, + submission: &ShellSubmission, + host_events: CommandEventSink, + ) -> Result { + self.validate_active_submission(submission)?; + if submission.target != ShellCommandTarget::Portable { + return Err(BuiltinShellError::FrontendCommand { id: submission.id }); + } + let shell = self.clone(); + let id = submission.id; + Ok(CommandEventSink::new(move |event| { + shell.record_command_event(id, event.clone()); + host_events.emit(event); + })) + } + + /// Marks a portable command as successfully completed by its host executor. + pub fn complete_portable(&self, id: ShellCommandId) -> Result<(), BuiltinShellError> { + self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed) + } + + /// Marks a portable command as cooperatively cancelled by its host executor. + pub fn cancel_portable(&self, id: ShellCommandId) -> Result<(), BuiltinShellError> { + self.finish(id, ShellExecutionStatus::Cancelled, ShellTranscriptContent::Cancelled) + } + + /// Marks a frontend-routed command as successfully completed. + pub fn complete_frontend(&self, id: ShellCommandId) -> Result<(), BuiltinShellError> { + self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed) + } + + /// Marks a frontend-routed command as failed with a user-facing message. + pub fn fail_frontend(&self, id: ShellCommandId, message: impl Into) -> Result<(), BuiltinShellError> { + self.fail_submission(id, message) + } + + /// Marks an active submission as failed before or during host routing. + pub fn fail_submission(&self, id: ShellCommandId, message: impl Into) -> Result<(), BuiltinShellError> { + self.finish( + id, + ShellExecutionStatus::Failed, + ShellTranscriptContent::Failed(message.into()), + ) + } + + /// Requests cooperative cancellation of the active command. + pub fn cancel_active(&self) -> Result, BuiltinShellError> { + let id = { + let mut state = self.lock_state()?; + let Some(active) = state.active.as_mut() else { + return Ok(None); + }; + active.cancellation.cancel(); + active.status = ShellExecutionStatus::Cancelling; + let id = active.id; + if let Some(record) = state.executions.iter_mut().find(|record| record.id == id) { + record.status = ShellExecutionStatus::Cancelling; + } + id + }; + self.events.emit(ShellEvent::StateChanged { + id, + status: ShellExecutionStatus::Cancelling, + }); + Ok(Some(id)) + } + + /// Selects the previous command-line history entry. + pub fn previous_history(&self, current_input: &str) -> Result, BuiltinShellError> { + let mut state = self.lock_state()?; + if state.history.is_empty() { + return Ok(None); + } + let index = match state.history_cursor { + Some(index) => index.saturating_sub(1), + None => { + state.navigation_draft = current_input.to_owned(); + state.history.len() - 1 + } + }; + state.history_cursor = Some(index); + Ok(state.history.get(index).cloned()) + } + + /// Selects the next command-line history entry or restores the input draft. + pub fn next_history(&self) -> Result, BuiltinShellError> { + let mut state = self.lock_state()?; + let Some(index) = state.history_cursor else { + return Ok(None); + }; + if index + 1 < state.history.len() { + let next = index + 1; + state.history_cursor = Some(next); + return Ok(state.history.get(next).cloned()); + } + state.history_cursor = None; + Ok(Some(state.navigation_draft.clone())) + } + + /// Returns full replacement lines matching the unfinished shell token. + /// + /// # Parameters + /// + /// * `input` is the current editable command line up to the caret. + /// + /// # Returns + /// + /// Sorted, de-duplicated replacement lines, or an empty list when the input + /// is not a completable prefix. + /// + /// # Notes + /// + /// Completion is derived from the built-in grammar alone and never reads + /// session state, so it stays an associated function rather than a method. + pub fn complete(input: &str) -> Vec { + crate::grammar::complete_builtin_shell_line(input) + } + + /// Returns a presentation-safe copy of the current session state. + pub fn snapshot(&self) -> Result { + let state = self.lock_state()?; + Ok(BuiltinShellSnapshot { + working_directory: state.context.working_directory.clone(), + active: state.active.as_ref().map(|active| ShellActiveCommand { + id: active.id, + command_id: active.command_id, + target: active.target, + status: active.status, + }), + executions: state.executions.clone(), + transcript: state.transcript.clone(), + }) + } + + /// Validates that a prepared submission still owns the foreground slot. + fn validate_active_submission(&self, submission: &ShellSubmission) -> Result<(), BuiltinShellError> { + let state = self.lock_state()?; + match state.active.as_ref() { + Some(active) if active.id == submission.id => Ok(()), + _ => Err(BuiltinShellError::InactiveCommand { id: submission.id }), + } + } + + /// Returns whether cancellation was requested for the active submission. + fn active_is_cancelled(&self, id: ShellCommandId) -> Result { + let state = self.lock_state()?; + match state.active.as_ref() { + Some(active) if active.id == id => Ok(active.cancellation.is_cancelled()), + _ => Err(BuiltinShellError::InactiveCommand { id }), + } + } + + /// Records one frontend-neutral event without holding the lock during notification. + fn record_command_event(&self, id: ShellCommandId, event: CommandExecutionEvent) { + let recorded = self + .lock_state() + .map(|mut state| { + if !matches!(state.active.as_ref(), Some(active) if active.id == id) { + return false; + } + match &event { + CommandExecutionEvent::Progress(progress) => update_progress(&mut state.transcript, id, progress.clone()), + CommandExecutionEvent::Message(message) => state.transcript.push(ShellTranscriptEntry { + command_id: id, + content: ShellTranscriptContent::Message(message.clone()), + }), + CommandExecutionEvent::Artifact(artifact) => state.transcript.push(ShellTranscriptEntry { + command_id: id, + content: ShellTranscriptContent::Artifact(artifact.clone()), + }), + CommandExecutionEvent::Started { .. } | CommandExecutionEvent::Completed { .. } => {} + } + true + }) + .unwrap_or(false); + if recorded { + self.events.emit(ShellEvent::CommandExecution { id, event }); + } + } + + /// Moves the active command into a terminal state and appends its marker. + fn finish( + &self, + id: ShellCommandId, + status: ShellExecutionStatus, + content: ShellTranscriptContent, + ) -> Result<(), BuiltinShellError> { + { + let mut state = self.lock_state()?; + if !matches!(state.active.as_ref(), Some(active) if active.id == id) { + return Err(BuiltinShellError::InactiveCommand { id }); + } + if let Some(record) = state.executions.iter_mut().find(|record| record.id == id) { + record.status = status; + } + state.transcript.push(ShellTranscriptEntry { + command_id: id, + content, + }); + state.active = None; + } + self.events.emit(ShellEvent::StateChanged { id, status }); + Ok(()) + } + + /// Locks the shared session state and maps mutex poisoning into a typed error. + fn lock_state(&self) -> Result, BuiltinShellError> { + self.state.lock().map_err(|_| BuiltinShellError::StateUnavailable) + } + + /// Retains a rejected interactive line for conventional shell history. + fn remember_unsubmitted_input(&self, input: String) -> Result<(), BuiltinShellError> { + let mut state = self.lock_state()?; + state.history.push(input); + state.history_cursor = None; + state.navigation_draft.clear(); + Ok(()) + } +} + +/// Replaces the active progress row so frequent updates do not grow scrollback. +fn update_progress(transcript: &mut Vec, id: ShellCommandId, progress: CommandProgress) { + if let Some(entry) = transcript + .iter_mut() + .rev() + .find(|entry| entry.command_id == id && matches!(entry.content, ShellTranscriptContent::Progress(_))) + { + entry.content = ShellTranscriptContent::Progress(progress); + } else { + transcript.push(ShellTranscriptEntry { + command_id: id, + content: ShellTranscriptContent::Progress(progress), + }); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use chitin_command::CommandOutputFormat; + use chitin_databases::{ClientConfig, providers::rcsb::StructureFormat}; + + use super::*; + + #[test] + fn submit_should_route_portable_and_frontend_commands() -> Result<(), BuiltinShellError> { + let portable_shell = BuiltinShell::new("."); + let portable = portable_shell.submit("structure validate model.pdb")?.into_command()?; + let frontend_shell = BuiltinShell::new("."); + let frontend = frontend_shell.submit("tab.close")?.into_command()?; + + assert_eq!(portable.target(), ShellCommandTarget::Portable); + assert_eq!(frontend.target(), ShellCommandTarget::Frontend); + Ok(()) + } + + #[test] + fn submit_should_reject_a_second_foreground_command() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let first = shell.submit("tab.close")?.into_command()?; + + let result = shell.submit("tab.focus_next"); + + assert!(matches!(result, Err(BuiltinShellError::Busy { active_id }) if active_id == first.id())); + Ok(()) + } + + #[test] + fn history_navigation_should_restore_the_current_draft() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let first = shell.submit("tab.close")?.into_command()?; + shell.complete_frontend(first.id())?; + let second = shell.submit("workspace.toggle_workspace")?.into_command()?; + shell.complete_frontend(second.id())?; + + let newest = shell.previous_history("structure inspect draft.pdb")?; + let oldest = shell.previous_history("")?; + let forward = shell.next_history()?; + let draft = shell.next_history()?; + + assert_eq!(newest.as_deref(), Some("workspace.toggle_workspace")); + assert_eq!(oldest.as_deref(), Some("tab.close")); + assert_eq!(forward.as_deref(), Some("workspace.toggle_workspace")); + assert_eq!(draft.as_deref(), Some("structure inspect draft.pdb")); + Ok(()) + } + + #[test] + fn completion_should_not_require_session_state() { + assert!(BuiltinShell::complete("cl").contains(&"clear".to_owned())); + } + + #[test] + fn rejected_input_should_remain_in_history() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + + assert!( + shell + .submit_line("not-a-command", ShellInvocationSource::Interactive) + .is_err() + ); + assert_eq!(shell.previous_history("")?, Some("not-a-command".to_owned())); + Ok(()) + } + + #[test] + fn help_line_should_return_display_output() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + + let result = shell.submit("db")?; + + assert!(matches!(result, ShellLineSubmission::Display(help) if help.contains("Usage: chitin db "))); + Ok(()) + } + + #[test] + fn help_line_should_not_reserve_the_execution_slot() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + + let _ = shell.submit("db")?; + + assert!(shell.snapshot()?.active.is_none()); + Ok(()) + } + + #[test] + fn clear_should_reset_transcript_without_erasing_history() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let submission = shell.submit("tab.close")?.into_command()?; + shell.complete_frontend(submission.id())?; + + let result = shell.submit("clear")?; + let snapshot = shell.snapshot()?; + + assert!(matches!( + result, + ShellLineSubmission::ShellBuiltin(ShellBuiltinEffect::ClearScrollback) + )); + assert!(snapshot.transcript.is_empty()); + assert_eq!(shell.previous_history("")?.as_deref(), Some("clear")); + Ok(()) + } + + #[test] + fn cancel_should_update_the_active_command_and_token() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let submission = shell.submit("structure validate model.pdb")?.into_command()?; + + let cancelled = shell.cancel_active()?; + let snapshot = shell.snapshot()?; + + assert_eq!(cancelled, Some(submission.id())); + assert!(submission.context().cancellation.is_cancelled()); + assert_eq!( + snapshot.active.map(|active| active.status), + Some(ShellExecutionStatus::Cancelling) + ); + Ok(()) + } + + #[tokio::test] + async fn portable_execution_should_record_validation_completion() -> Result<(), BuiltinShellError> { + let observed = Arc::new(Mutex::new(Vec::new())); + let callback_observed = observed.clone(); + let shell = BuiltinShell::with_event_sink( + ".", + ShellEventSink::new(move |event| { + if let Ok(mut events) = callback_observed.lock() { + events.push(event); + } + }), + ); + shell.set_standard_input(Some(Arc::from( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".as_slice(), + )))?; + let submission = shell.submit("structure validate - --format pdb")?.into_command()?; + let executor = CommandExecutor::new(ClientConfig::default()); + + let result = shell.execute_portable(submission, &executor).await?; + let snapshot = shell.snapshot()?; + + assert!(matches!( + result.outcome, + CommandOutcome::StructureValidation(validation) + if validation.format == StructureFormat::Pdb + && validation.output == CommandOutputFormat::Text + && validation.is_valid() + )); + assert!(snapshot.active.is_none()); + assert!(matches!( + snapshot.executions.as_slice(), + [ShellExecutionRecord { + status: ShellExecutionStatus::Succeeded, + .. + }] + )); + assert!(observed.lock().map(|events| !events.is_empty()).unwrap_or(false)); + Ok(()) + } + + #[test] + fn frontend_completion_should_append_a_terminal_marker() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let submission = shell.submit("tab.close")?.into_command()?; + + shell.complete_frontend(submission.id())?; + let snapshot = shell.snapshot()?; + + assert!(snapshot.active.is_none()); + assert!(matches!( + snapshot.transcript.last(), + Some(ShellTranscriptEntry { + content: ShellTranscriptContent::Completed, + .. + }) + )); + Ok(()) + } + + #[test] + fn typed_agent_submission_should_preserve_source_and_arguments() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let parsed = parse_builtin_command_line("structure validate model.pdb --format pdb")?; + let BuiltinCommandLine::Portable(parsed) = parsed else { + return Err(BuiltinShellError::DisplayOnly { + output: "expected an executable structure command".to_owned(), + }); + }; + let parsed = ChitinCommand::from(parsed); + + let submission = shell.submit_typed( + "agent validation request", + parsed.clone(), + ShellInvocationSource::Agent { + name: "structure-agent".to_owned(), + }, + )?; + let snapshot = shell.snapshot()?; + + assert_eq!(submission.command(), &parsed); + assert_eq!( + submission.source(), + &ShellInvocationSource::Agent { + name: "structure-agent".to_owned() + } + ); + assert!(matches!( + snapshot.executions.as_slice(), + [ShellExecutionRecord { + command, + source: ShellInvocationSource::Agent { name }, + .. + }] if command == &parsed && name == "structure-agent" + )); + Ok(()) + } +} diff --git a/crates/chitin-builtin-shell/src/terminal.rs b/crates/chitin-builtin-shell/src/terminal.rs new file mode 100644 index 00000000..de53bdf2 --- /dev/null +++ b/crates/chitin-builtin-shell/src/terminal.rs @@ -0,0 +1,307 @@ +//! Terminal byte-stream adapter for Chitin's in-process shell. + +use chitin_terminal::{ + TerminalProfile, TerminalProgram, TerminalProgramInput, TerminalSession, TerminalSessionError, TerminalSize, +}; + +/// Semantic input produced by the built-in terminal's line discipline. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BuiltinTerminalEvent { + /// A complete command line was submitted with Enter. + Submit(String), + /// The active line or command was interrupted with Ctrl+C. + Interrupt, + /// The user requested completion with Tab. + Complete(String), + /// The user requested an older history entry. + PreviousHistory, + /// The user requested a newer history entry. + NextHistory, + /// The terminal grid changed size. + Resize(TerminalSize), + /// The terminal frontend disconnected. + Shutdown, +} + +/// Adapts terminal input bytes into shell lines and writes ANSI output back. +/// +/// This type performs the same line-discipline role that an interactive Bash +/// or Fish process performs behind a PTY. Command parsing and execution remain +/// owned by [`BuiltinShell`](crate::BuiltinShell) and its desktop host. +pub struct BuiltinTerminalProgram { + terminal: TerminalProgram, + prompt: String, + line: String, + waiting_for_command: bool, + running_output_lines: usize, + escape: Vec, +} + +impl BuiltinTerminalProgram { + /// Creates an in-process terminal session and displays its first prompt. + /// + /// # Parameters + /// + /// * `size` is the initial terminal grid and cell geometry. + /// * `prompt` is ANSI-capable text displayed before each editable line. + /// + /// # Returns + /// + /// The frontend session and its built-in shell program endpoint. + pub fn connect( + size: TerminalSize, + prompt: impl Into, + ) -> Result<(TerminalSession, Self), TerminalSessionError> { + let (session, terminal) = TerminalSession::in_process(TerminalProfile::BuiltinShell, size); + let program = Self { + terminal, + prompt: prompt.into(), + line: String::new(), + waiting_for_command: false, + running_output_lines: 0, + escape: Vec::new(), + }; + program.write_prompt()?; + Ok((session, program)) + } + + /// Processes pending emulator input into semantic shell events. + pub fn poll(&mut self) -> Result, TerminalSessionError> { + let mut events = Vec::new(); + for input in self.terminal.drain_input()? { + match input { + TerminalProgramInput::Bytes(bytes) => self.process_bytes(&bytes, &mut events)?, + TerminalProgramInput::Resize(size) => events.push(BuiltinTerminalEvent::Resize(size)), + TerminalProgramInput::Shutdown => events.push(BuiltinTerminalEvent::Shutdown), + } + } + Ok(events) + } + + /// Writes command output and restores an editable prompt. + pub fn finish_command(&mut self, output: &str) -> Result<(), TerminalSessionError> { + self.clear_running_output()?; + if !output.is_empty() { + self.write_normalized_lines(output)?; + } + self.line.clear(); + self.waiting_for_command = false; + self.write_prompt() + } + + /// Replaces transient output for the command currently holding the prompt. + pub fn update_command_output(&mut self, output: &str) -> Result<(), TerminalSessionError> { + self.clear_running_output()?; + if output.is_empty() { + return Ok(()); + } + let normalized = output.replace("\r\n", "\n").replace('\n', "\r\n"); + self.terminal.output().write(normalized.as_bytes())?; + self.running_output_lines = normalized.lines().count().max(1); + Ok(()) + } + + /// Replaces the editable line, as used by history and completion. + pub fn replace_line(&mut self, line: impl Into) -> Result<(), TerminalSessionError> { + self.line = line.into(); + self.redraw_line() + } + + /// Clears the VT screen and displays a fresh prompt. + pub fn clear_screen(&mut self) -> Result<(), TerminalSessionError> { + self.line.clear(); + self.waiting_for_command = false; + self.terminal.output().write(b"\x1b[2J\x1b[H")?; + self.write_prompt() + } + + /// Displays non-command output above the current editable line. + pub fn show_notice(&self, output: &str) -> Result<(), TerminalSessionError> { + self.terminal.output().write(b"\r\x1b[2K")?; + self.write_normalized_lines(output)?; + self.redraw_line() + } + + /// Returns the line currently owned by the in-process line discipline. + pub fn line(&self) -> &str { + &self.line + } + + fn process_bytes( + &mut self, + bytes: &[u8], + events: &mut Vec, + ) -> Result<(), TerminalSessionError> { + let mut printable = Vec::new(); + for &byte in bytes { + if !self.escape.is_empty() || byte == 0x1b { + self.flush_printable(&mut printable)?; + self.process_escape_byte(byte, events); + continue; + } + match byte { + b'\r' | b'\n' if !self.waiting_for_command => { + self.flush_printable(&mut printable)?; + self.terminal.output().write(b"\r\n")?; + self.waiting_for_command = true; + self.running_output_lines = 0; + events.push(BuiltinTerminalEvent::Submit(self.line.clone())); + } + 0x03 => { + self.flush_printable(&mut printable)?; + self.terminal.output().write(b"^C\r\n")?; + events.push(BuiltinTerminalEvent::Interrupt); + if !self.waiting_for_command { + self.line.clear(); + self.write_prompt()?; + } + } + 0x7f | 0x08 if !self.waiting_for_command => { + self.flush_printable(&mut printable)?; + self.line.pop(); + self.redraw_line()?; + } + b'\t' if !self.waiting_for_command => { + self.flush_printable(&mut printable)?; + events.push(BuiltinTerminalEvent::Complete(self.line.clone())); + } + 0x20..=0x7e if !self.waiting_for_command => printable.push(byte), + 0x80..=0xff if !self.waiting_for_command => printable.push(byte), + _ => {} + } + } + self.flush_printable(&mut printable) + } + + fn process_escape_byte(&mut self, byte: u8, events: &mut Vec) { + self.escape.push(byte); + match self.escape.as_slice() { + b"\x1b[A" => { + events.push(BuiltinTerminalEvent::PreviousHistory); + self.escape.clear(); + } + b"\x1b[B" => { + events.push(BuiltinTerminalEvent::NextHistory); + self.escape.clear(); + } + sequence if sequence.len() >= 3 || (sequence.len() == 2 && sequence[1] != b'[') => { + self.escape.clear(); + } + _ => {} + } + } + + fn flush_printable(&mut self, bytes: &mut Vec) -> Result<(), TerminalSessionError> { + if bytes.is_empty() { + return Ok(()); + } + let text = String::from_utf8_lossy(bytes); + self.line.push_str(&text); + self.terminal.output().write(text.as_bytes())?; + bytes.clear(); + Ok(()) + } + + fn redraw_line(&self) -> Result<(), TerminalSessionError> { + let redraw = format!("\r\x1b[2K{}{}", self.prompt, self.line); + self.terminal.output().write(redraw.as_bytes()) + } + + fn write_prompt(&self) -> Result<(), TerminalSessionError> { + self.terminal.output().write(self.prompt.as_bytes()) + } + + fn write_normalized_lines(&self, output: &str) -> Result<(), TerminalSessionError> { + let normalized = output.replace("\r\n", "\n").replace('\n', "\r\n"); + self.terminal.output().write(normalized.as_bytes())?; + if !normalized.ends_with("\r\n") { + self.terminal.output().write(b"\r\n")?; + } + Ok(()) + } + + fn clear_running_output(&mut self) -> Result<(), TerminalSessionError> { + if self.running_output_lines == 0 { + return Ok(()); + } + let mut clear = String::from("\r\x1b[2K"); + for _ in 1..self.running_output_lines { + clear.push_str("\x1b[1A\r\x1b[2K"); + } + self.terminal.output().write(clear.as_bytes())?; + self.running_output_lines = 0; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connected_program() -> (TerminalSession, BuiltinTerminalProgram) { + let result = BuiltinTerminalProgram::connect(TerminalSize::new(80, 24, 8, 16), "chitin ❯ "); + let Ok(connected) = result else { + panic!("in-process terminal should connect"); + }; + connected + } + + #[test] + fn enter_should_submit_the_line_received_as_terminal_bytes() { + let (session, mut program) = connected_program(); + assert!(session.write(b"structure validate file.cif\r").is_ok()); + + let events = program.poll(); + + assert_eq!( + events.ok(), + Some(vec![BuiltinTerminalEvent::Submit("structure validate file.cif".into())]), + ); + } + + #[test] + fn backspace_should_edit_and_redraw_the_terminal_line() { + let (session, mut program) = connected_program(); + assert!(session.write(b"hellp\x7fo").is_ok()); + + let poll_result = program.poll(); + + assert!(poll_result.is_ok()); + assert_eq!(program.line(), "hello"); + } + + #[test] + fn finish_should_write_output_through_the_shared_vt_emulator() { + let (session, mut program) = connected_program(); + + let finish_result = program.finish_command("command completed"); + let text = session + .snapshot() + .cells + .iter() + .map(|cell| cell.character) + .collect::(); + + assert!(finish_result.is_ok()); + assert!(text.contains("command completed")); + } + + #[test] + fn running_output_should_be_replaced_before_final_output() { + let (session, mut program) = connected_program(); + assert!(program.update_command_output("Downloading 1 / 2").is_ok()); + assert!(program.update_command_output("Downloading 2 / 2").is_ok()); + + let finish_result = program.finish_command("Download complete"); + let text = session + .snapshot() + .cells + .iter() + .map(|cell| cell.character) + .collect::(); + + assert!(finish_result.is_ok()); + assert!(text.contains("Download complete")); + assert!(!text.contains("Downloading 1 / 2")); + } +} diff --git a/crates/chitin-cli/Cargo.toml b/crates/chitin-cli/Cargo.toml index 7ef73898..56d859c5 100644 --- a/crates/chitin-cli/Cargo.toml +++ b/crates/chitin-cli/Cargo.toml @@ -25,13 +25,11 @@ path = "src/main.rs" [dependencies] chitin-command = { version = "0.1.0", path = "../chitin-command" } -chitin-bio = { version = "0.1.0", path = "../chitin-bio" } chitin-databases = { version = "0.1.0", path = "../chitin-databases" } clap = { version = "4.6", features = ["derive"] } clap_complete = "4.6" console = "0.16" indicatif = "0.18" -serde_json = "1.0" thiserror = "2.0" tokio = { version = "1.52", features = ["macros", "rt-multi-thread"] } diff --git a/crates/chitin-cli/src/cli.rs b/crates/chitin-cli/src/cli.rs index 71009013..a6e8941f 100644 --- a/crates/chitin-cli/src/cli.rs +++ b/crates/chitin-cli/src/cli.rs @@ -1,256 +1,131 @@ -//! Command-line schema and command-bus routing. +//! Process CLI schema and command-bus routing. -use std::path::PathBuf; +use std::{io::Read, path::PathBuf}; -use chitin_command::{ChitinCommand, DatabaseCommand, StructureCommand}; -use chitin_databases::providers::rcsb::StructureFormat; -use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use chitin_command::{ + CommandExecutionContext, CommandExecutor, CommandReportStatus, DatabaseCommand, PortableCommand, PortableCommandArgs, + RcsbDownloadArguments, StructureCommand, +}; +use chitin_databases::ClientConfig; +use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{Shell, generate}; -use crate::{download::download_rcsb, error::CliError}; +use crate::{download::terminal_event_sink, error::CliError}; /// Root command parsed by the `chitin` binary. #[derive(Debug, Parser)] #[command(name = "chitin", version, about = "Chitin structural biology tools")] pub(crate) struct Cli { - /// Selected top-level workflow. + /// Selected command-line workflow. #[command(subcommand)] pub(crate) command: CliCommand, } -/// Top-level CLI workflows. +/// CLI-specific actions composed with the shared portable command tree. #[derive(Debug, Subcommand)] pub(crate) enum CliCommand { - /// Work with external biological databases. - #[command(name = "db", visible_alias = "databases")] - Database(DatabaseCommandArgs), - /// Inspect or validate a local PDB/mmCIF structure file. - Structure(StructureCommandArgs), + /// Execute a portable Chitin workflow. + #[command(flatten)] + Portable(PortableCommandArgs), /// Generate shell completion scripts. Completions { shell: Shell }, } -#[derive(Debug, Args)] -pub(crate) struct StructureCommandArgs { - #[command(subcommand)] - command: StructureSubcommand, -} - -#[derive(Debug, Subcommand)] -enum StructureSubcommand { - /// Print a human-readable or JSON structure summary. - Inspect(StructureInspectArgs), - /// Parse a structure and verify its indexed model invariants. - Validate(StructureValidateArgs), -} - -#[derive(Debug, Args)] -struct StructureInspectArgs { - #[command(flatten)] - input: StructureInputArgs, - /// Output representation. - #[arg(long, value_enum, default_value_t = OutputArg::Text)] - output: OutputArg, - /// Include chains, metadata, assembly, and diagnostics. - #[arg(long)] - verbose: bool, -} - -#[derive(Debug, Args)] -struct StructureValidateArgs { - #[command(flatten)] - input: StructureInputArgs, - /// Output representation. - #[arg(long, value_enum, default_value_t = OutputArg::Text)] - output: OutputArg, -} - -#[derive(Debug, Args)] -pub(crate) struct StructureInputArgs { - /// Structure file path, or `-` to read stdin. - #[arg(value_name = "FILE")] - pub(crate) input: PathBuf, - /// Input format; inferred from the extension when omitted. - #[arg(long, value_enum)] - pub(crate) format: Option, -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -pub(crate) enum OutputArg { - /// Human-readable terminal output. - Text, - /// Stable machine-readable JSON output. - Json, -} - -#[derive(Debug, Args)] -pub(crate) struct DatabaseCommandArgs { - #[command(subcommand)] - command: DatabaseSubcommand, -} - -#[derive(Debug, Subcommand)] -enum DatabaseSubcommand { - /// Download an RCSB structure file. - Rcsb(RcsbCommandArgs), -} - -#[derive(Debug, Args)] -struct RcsbCommandArgs { - #[command(subcommand)] - command: RcsbSubcommand, -} - -#[derive(Debug, Subcommand)] -enum RcsbSubcommand { - /// Download a PDB or mmCIF structure. - Download { - /// Comma-separated list of four-character PDB identifiers, such as 4HHB,1YTH. - #[arg(long, value_name = "PDB_ID")] - id: String, - /// Structure format to download. - #[arg(long, value_enum, default_value_t = FormatArg::Pdb)] - format: FormatArg, - /// Output file or directory. Existing directories receive a generated filename. - #[arg(short, long, value_name = "PATH")] - output: Option, - }, -} - -/// CLI spelling for the two RCSB structure formats. -#[derive(Clone, Copy, Debug, ValueEnum)] -pub(crate) enum FormatArg { - /// Legacy PDB format. - Pdb, - /// PDBx/mmCIF format. - Mmcif, -} - -impl FormatArg { - /// Converts the CLI value into the shared provider format. - pub(crate) fn structure_format(self) -> StructureFormat { - match self { - Self::Pdb => StructureFormat::Pdb, - Self::Mmcif => StructureFormat::Mmcif, - } - } -} - /// Dispatches a parsed CLI workflow. /// /// # Parameters /// -/// * `command` is the validated top-level CLI command. +/// * `command` is the validated top-level CLI action. /// /// # Returns /// -/// Returns `Ok(())` after the selected workflow completes. +/// The report status after execution or completion generation finishes. /// /// # Errors /// -/// Returns [`CliError`] when command execution or output generation fails. -pub(crate) async fn dispatch(command: CliCommand) -> Result<(), CliError> { +/// Returns [`CliError`] when typed conversion, execution, or output rendering +/// fails. +pub(crate) async fn dispatch(command: CliCommand) -> Result { match command { CliCommand::Completions { shell } => { let mut command = Cli::command(); generate(shell, &mut command, "chitin", &mut std::io::stdout()); - Ok(()) - } - CliCommand::Database(database) => dispatch_database_command(database.command).await, - CliCommand::Structure(structure) => dispatch_structure_command(structure.command).await, - } -} - -/// Dispatches a structure command through the shared command bus. -async fn dispatch_structure_command(command: StructureSubcommand) -> Result<(), CliError> { - match command { - StructureSubcommand::Inspect(args) => { - crate::structure::dispatch( - ChitinCommand::from(StructureCommand::Inspect), - args.input, - Some(args.output), - args.verbose, - ) - .await - } - StructureSubcommand::Validate(args) => { - crate::structure::dispatch( - ChitinCommand::from(StructureCommand::Validate), - args.input, - Some(args.output), - false, - ) - .await + Ok(CommandReportStatus::Succeeded) } + CliCommand::Portable(arguments) => dispatch_command(arguments.into_command()?).await, } } -/// Dispatches a database command to its provider-specific workflow. +/// Routes one CLI request through the shared portable command executor. /// /// # Parameters /// -/// * `command` is the parsed database subcommand. +/// * `command` contains the validated portable command and arguments. /// /// # Returns /// -/// Returns `Ok(())` after the database workflow completes. +/// The rendered command's domain-level completion status. /// /// # Errors /// -/// Returns [`CliError`] when the selected workflow fails. -async fn dispatch_database_command(command: DatabaseSubcommand) -> Result<(), CliError> { - match command { - DatabaseSubcommand::Rcsb(command) => dispatch_rcsb_command(command.command).await, - } +/// Returns [`CliError`] when process context, command execution, or output +/// rendering fails. +async fn dispatch_command(command: PortableCommand) -> Result { + let context = execution_context(&command)?; + let executor = CommandExecutor::new(ClientConfig::default()); + let outcome = executor.execute(command, context, terminal_event_sink()).await?; + Ok(crate::structure::render_outcome(&outcome)) } -/// Dispatches an RCSB subcommand to the shared command bus. +/// Builds frontend-neutral execution context from the current process state. /// /// # Parameters /// -/// * `command` is the parsed RCSB subcommand and its download options. +/// * `command` identifies which optional process resources are required. /// /// # Returns /// -/// Returns `Ok(())` after the RCSB workflow completes. -/// -/// # Errors -/// -/// Returns [`CliError`] when the RCSB download or output resolution fails. -async fn dispatch_rcsb_command(command: RcsbSubcommand) -> Result<(), CliError> { - match command { - RcsbSubcommand::Download { id, format, output } => { - let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure); - dispatch_command(command, id, format.structure_format(), output).await - } +/// Working-directory, download-root, and standard-input data for the executor. +fn execution_context(command: &PortableCommand) -> Result { + let working_directory = std::env::current_dir().map_err(CliError::WorkingDirectory)?; + let mut context = CommandExecutionContext::new(working_directory); + if requires_default_download_root(command) { + context = context.with_default_download_root(home_directory()?.join(".chitin").join("download")); + } + if requires_standard_input(command) { + let mut bytes = Vec::new(); + std::io::stdin() + .read_to_end(&mut bytes) + .map_err(CliError::StandardInput)?; + context = context.with_standard_input(bytes); } + Ok(context) } -/// Routes the CLI request through the shared typed command bus. -/// -/// # Parameters -/// -/// * `command` identifies the typed command to dispatch. -/// * `raw_id` contains one or more comma-separated PDB identifiers. -/// * `format` selects the structure file format. -/// * `output` optionally overrides the generated output path. -/// -/// # Returns -/// -/// Returns `Ok(())` after the command completes successfully. -/// -/// # Errors -/// -/// Returns [`CliError`] when the command is unsupported or the RCSB download -/// fails. -async fn dispatch_command( - command: ChitinCommand, - raw_id: String, - format: StructureFormat, - output: Option, -) -> Result<(), CliError> { +/// Returns whether a database command needs the process download root. +fn requires_default_download_root(command: &PortableCommand) -> bool { + matches!( + command, + PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + output: None, + .. + })) + ) +} + +/// Returns whether a structure command reads bytes from standard input. +fn requires_standard_input(command: &PortableCommand) -> bool { match command { - ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure) => download_rcsb(raw_id, format, output).await, - other => Err(CliError::UnsupportedCommand(other.id())), + PortableCommand::Structure(StructureCommand::Inspect(arguments)) => arguments.input.input.as_os_str() == "-", + PortableCommand::Structure(StructureCommand::Validate(arguments)) => arguments.input.input.as_os_str() == "-", + PortableCommand::Database(_) => false, } } + +/// Finds the platform home directory used by default downloads. +fn home_directory() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or(CliError::HomeDirectory) +} diff --git a/crates/chitin-cli/src/download.rs b/crates/chitin-cli/src/download.rs index ac9fdbd8..0e251503 100644 --- a/crates/chitin-cli/src/download.rs +++ b/crates/chitin-cli/src/download.rs @@ -1,99 +1,87 @@ -//! RCSB download execution and terminal progress reporting. +//! Terminal presentation for portable command execution events. use std::{ - path::PathBuf, sync::{Arc, Mutex}, time::Duration, }; -use chitin_databases::{ - Client, ClientConfig, - providers::rcsb::{ - PdbId, RcsbBatchDownloadEvent, RcsbBatchDownloadRequest, RcsbDownloadError, RcsbError, StructureFormat, - }, -}; +use chitin_command::{CommandEventSink, CommandExecutionEvent, CommandMessageLevel, CommandProgress}; use indicatif::{ProgressBar, ProgressStyle}; -use crate::{error::CliError, output::resolve_output_path}; +/// Mutable terminal progress retained across command event callbacks. +#[derive(Default)] +struct DownloadProgressState { + /// Active progress bar for the current artifact. + bar: Option, + /// One-based stage currently represented by the progress bar. + stage_index: Option, +} -/// Downloads an RCSB artifact and writes it to the resolved output path. -/// -/// # Parameters -/// -/// * `raw_id` is the user-provided comma-separated list of four-character PDB -/// identifiers such as 4HHB,1YTH. -/// * `format` selects PDB or mmCIF content. -/// * `output` is an optional file or directory override. -/// -/// # Returns -/// -/// `Ok(())` after the artifact has been written and reported to the terminal. -pub(crate) async fn download_rcsb( - raw_ids: String, - format: StructureFormat, - output: Option, -) -> Result<(), CliError> { - // we use `,` as the splitter of different PDB IDs. - let ids = PdbId::parse_many(&raw_ids)?; - let multiple = ids.len() > 1; - let client = Client::new(ClientConfig::default()) - .map_err(|error| CliError::Rcsb(RcsbDownloadError::Provider(RcsbError::Transport(error))))?; - let requests = ids - .into_iter() - .map(|id| { - let destination = resolve_output_path(output.clone(), &id, format, multiple)?; - Ok(RcsbBatchDownloadRequest { - id, - format, - destination, - }) - }) - .collect::, CliError>>()?; - let progress: Arc>> = Arc::new(Mutex::new(None)); - let callback_progress = progress.clone(); - client - .rcsb() - .download_structures_to_paths(&requests, move |event| match event { - RcsbBatchDownloadEvent::Started { index, total, id } => { - let bar = ProgressBar::new_spinner(); - bar.enable_steady_tick(Duration::from_millis(100)); - if let Ok(style) = ProgressStyle::with_template(" {spinner} Downloading {bytes} ({elapsed})") { - bar.set_style(style); - } - eprintln!(" Downloading {index}/{total}: {id}"); - if let Ok(mut current) = callback_progress.lock() { - *current = Some(bar); - } - } - RcsbBatchDownloadEvent::Progress { - received, total_bytes, .. - } => { - if let Ok(current) = callback_progress.lock() - && let Some(bar) = current.as_ref() - { - if let Some(total_bytes) = total_bytes { - if bar.length().is_none() { - if let Ok(style) = - ProgressStyle::with_template(" Downloading [{bar:32.cyan/blue}] {bytes}/{total_bytes} ({eta})") - { - bar.set_style(style.progress_chars("##-")); - } - bar.set_length(total_bytes); - } - } - bar.set_position(received); - } - } - RcsbBatchDownloadEvent::Completed { id, path, .. } => { - if let Ok(mut current) = callback_progress.lock() - && let Some(bar) = current.take() - { - bar.finish_and_clear(); - } - println!(" ✓ Downloaded {id} ({})", format.label()); - println!(" ✓ Saved to {}", path.display()); +/// Creates a command-event sink that renders download progress in the terminal. +pub(crate) fn terminal_event_sink() -> CommandEventSink { + let state = Arc::new(Mutex::new(DownloadProgressState::default())); + CommandEventSink::new(move |event| { + let Ok(mut state) = state.lock() else { + return; + }; + render_event(&mut state, event); + }) +} + +/// Projects one frontend-neutral event into terminal progress and messages. +fn render_event(state: &mut DownloadProgressState, event: CommandExecutionEvent) { + match event { + CommandExecutionEvent::Progress(progress) => render_progress(state, progress), + CommandExecutionEvent::Artifact(artifact) => { + finish_progress_bar(state); + println!(" ✓ Saved to {}", artifact.path.display()); + } + CommandExecutionEvent::Message(message) => match message.level { + CommandMessageLevel::Info => println!(" ✓ {}", message.text), + CommandMessageLevel::Warning => eprintln!(" ! {}", message.text), + }, + CommandExecutionEvent::Completed { .. } => finish_progress_bar(state), + CommandExecutionEvent::Started { .. } => {} + } +} + +/// Updates or replaces the progress bar for the active download stage. +fn render_progress(state: &mut DownloadProgressState, progress: CommandProgress) { + if state.stage_index != Some(progress.stage_index) { + finish_progress_bar(state); + let bar = ProgressBar::new_spinner(); + bar.enable_steady_tick(Duration::from_millis(100)); + if let Ok(style) = ProgressStyle::with_template(" {spinner} Downloading {bytes} ({elapsed})") { + bar.set_style(style); + } + let label = progress.stage_label.as_deref().unwrap_or("structure"); + eprintln!( + " Downloading {}/{}: {label}", + progress.stage_index, progress.stage_count + ); + state.stage_index = Some(progress.stage_index); + state.bar = Some(bar); + } + + let Some(bar) = state.bar.as_ref() else { + return; + }; + if let Some(total) = progress.total { + if bar.length().is_none() { + if let Ok(style) = + ProgressStyle::with_template(" Downloading [{bar:32.cyan/blue}] {bytes}/{total_bytes} ({eta})") + { + bar.set_style(style.progress_chars("##-")); } - }) - .await - .map_err(CliError::Rcsb) + bar.set_length(total); + } + } + bar.set_position(progress.completed); +} + +/// Clears the current progress bar after an artifact or command completes. +fn finish_progress_bar(state: &mut DownloadProgressState) { + if let Some(bar) = state.bar.take() { + bar.finish_and_clear(); + } } diff --git a/crates/chitin-cli/src/error.rs b/crates/chitin-cli/src/error.rs index 515158cf..1f8d137b 100644 --- a/crates/chitin-cli/src/error.rs +++ b/crates/chitin-cli/src/error.rs @@ -1,42 +1,21 @@ //! Errors produced while parsing and executing CLI workflows. -use chitin_databases::providers::rcsb::{PdbIdError, PdbIdListError, RcsbDownloadError}; -use std::path::PathBuf; - /// Error returned by the Chitin CLI command handlers. #[derive(Debug, thiserror::Error)] pub(crate) enum CliError { - /// A comma-separated identifier list contains an invalid element. - #[error("{0}")] - InvalidPdbIdList(#[from] PdbIdListError), - /// The supplied PDB identifier failed provider validation. - #[error("invalid PDB ID: {0}")] - InvalidPdbId(#[from] PdbIdError), - /// The RCSB provider or local persistence step failed. - #[error("RCSB download failed: {0}")] - Rcsb(#[from] RcsbDownloadError), + /// Shared command-line arguments could not be converted into a typed command. + #[error(transparent)] + CommandLine(#[from] chitin_command::PortableCommandLineError), + /// A portable typed command could not be executed. + #[error(transparent)] + CommandExecution(#[from] chitin_command::CommandExecutionError), /// No platform home directory variable was available. #[error("could not determine the home directory; set HOME or USERPROFILE")] HomeDirectory, - /// Multiple downloads were given one explicit file path. - #[error("--output must be a directory when downloading multiple PDB IDs: {0}")] - MultipleOutputFile(std::path::PathBuf), - /// A command was not implemented by this CLI entry point. - #[error("unsupported CLI command: {0}")] - UnsupportedCommand(&'static str), - /// The structure input could not be read. - #[error("failed to read structure `{path}`: {source}")] - StructureRead { path: PathBuf, source: std::io::Error }, - /// The structure format could not be determined from the input. - #[error("could not determine structure format for `{0}`; use --format pdb or --format mmcif")] - StructureFormat(PathBuf), - /// The structure parser rejected the input. - #[error("failed to parse structure `{path}`: {message}")] - StructureParse { path: PathBuf, message: String }, - /// The parsed structure violated a model invariant. - #[error("structure validation failed for `{path}`: {message}")] - StructureValidation { path: PathBuf, message: String }, - /// JSON output could not be serialized. - #[error("failed to serialize structure output: {0}")] - Json(#[from] serde_json::Error), + /// The process working directory could not be read. + #[error("could not determine the current working directory: {0}")] + WorkingDirectory(std::io::Error), + /// Standard input could not be read for a structure command. + #[error("failed to read structure data from standard input: {0}")] + StandardInput(std::io::Error), } diff --git a/crates/chitin-cli/src/main.rs b/crates/chitin-cli/src/main.rs index 2bc32b99..c038a61c 100644 --- a/crates/chitin-cli/src/main.rs +++ b/crates/chitin-cli/src/main.rs @@ -4,11 +4,11 @@ mod cli; mod download; mod error; -mod output; mod structure; use std::process::ExitCode; +use chitin_command::CommandReportStatus; use clap::Parser; use console::Style; @@ -19,7 +19,8 @@ use crate::{cli::Cli, error::CliError}; async fn main() -> ExitCode { let cli = Cli::parse(); match cli::dispatch(cli.command).await { - Ok(()) => ExitCode::SUCCESS, + Ok(CommandReportStatus::Succeeded) => ExitCode::SUCCESS, + Ok(CommandReportStatus::Failed) => ExitCode::FAILURE, Err(error) => report_error(error), } } diff --git a/crates/chitin-cli/src/output.rs b/crates/chitin-cli/src/output.rs deleted file mode 100644 index e76a2975..00000000 --- a/crates/chitin-cli/src/output.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Output path resolution for downloaded structures. - -use std::path::PathBuf; - -use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; - -use crate::error::CliError; - -/// Resolves an optional CLI output into the final file path. -/// -/// # Parameters -/// -/// * `output` is an optional explicit file or directory. -/// * `id` supplies the generated filename when a directory is selected. -/// * `format` supplies the generated file extension and default directory. -/// -/// # Returns -/// -/// The path where the downloaded bytes should be written. -pub(crate) fn resolve_output_path( - output: Option, - id: &PdbId, - format: StructureFormat, - multiple: bool, -) -> Result { - let Some(output) = output else { - return Ok( - home_directory()? - .join(".chitin") - .join("download") - .join(format.id()) - .join(format.filename(id)), - ); - }; - if output.is_dir() { - return Ok(output.join(format.filename(id))); - } - if multiple && (output.extension().is_some() || output.exists()) { - return Err(CliError::MultipleOutputFile(output)); - } - if multiple { - return Ok(output.join(format.filename(id))); - } - Ok(output) -} - -/// Finds the platform home directory used by the default output path. -fn home_directory() -> Result { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) - .ok_or(CliError::HomeDirectory) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_id() -> PdbId { - match PdbId::new("4hhb") { - Ok(id) => id, - Err(_) => panic!("test identifier must be valid"), - } - } - - #[test] - fn existing_directory_should_receive_generated_filename() { - let directory = std::env::temp_dir(); - let resolved = resolve_output_path(Some(directory.clone()), &test_id(), StructureFormat::Pdb, false); - assert!(matches!(resolved, Ok(path) if path == directory.join("4HHB.pdb"))); - } - - #[test] - fn nonexistent_extensionless_output_should_be_used_as_file() { - let path = PathBuf::from("custom/structure"); - let resolved = resolve_output_path(Some(path.clone()), &test_id(), StructureFormat::Pdb, false); - assert!(matches!(resolved, Ok(resolved) if resolved == path)); - } - - #[test] - fn path_with_extension_should_be_used_as_file() { - let path = PathBuf::from("custom/output.cif"); - let resolved = resolve_output_path(Some(path.clone()), &test_id(), StructureFormat::Mmcif, false); - assert!(matches!(resolved, Ok(resolved) if resolved == path)); - } -} diff --git a/crates/chitin-cli/src/structure.rs b/crates/chitin-cli/src/structure.rs index 6aed3d3e..7f208d3e 100644 --- a/crates/chitin-cli/src/structure.rs +++ b/crates/chitin-cli/src/structure.rs @@ -1,328 +1,38 @@ -//! Local PDB and mmCIF inspection workflows. +//! ANSI presentation adapter for shared command reports. -use std::{ - fs::File, - io::{self, Read}, - path::Path, +use chitin_command::{ + CommandOutcome, CommandOutputTone, CommandReportStatus, render_outcome as render_command_outcome, }; - -use chitin_bio::structure::{MmcifParser, PdbParser, Structure, StructureParseResult}; -use chitin_command::{ChitinCommand, StructureCommand}; -use chitin_databases::providers::rcsb::StructureFormat; use console::Style; -use serde_json::{Value, json}; - -use crate::{ - cli::{FormatArg, OutputArg, StructureInputArgs}, - error::CliError, -}; - -/// Executes a structure inspection or validation command. -pub(crate) async fn dispatch( - command: ChitinCommand, - input: StructureInputArgs, - output: Option, - verbose: bool, -) -> Result<(), CliError> { - let (format, bytes) = read_input(&input.input, input.format)?; - let parsed = parse_structure(format, &input.input, &bytes)?; - match command { - ChitinCommand::Structure(StructureCommand::Inspect) => print_inspection( - &input.input, - format, - bytes.len(), - &parsed, - output.unwrap_or(OutputArg::Text), - verbose, - ), - ChitinCommand::Structure(StructureCommand::Validate) => { - validate_structure(&input.input, format, &parsed, output.unwrap_or(OutputArg::Text)) - } - other => Err(CliError::UnsupportedCommand(other.id())), - } -} - -/// Reads a local file or stdin and resolves its structure format. -fn read_input(path: &Path, requested: Option) -> Result<(StructureFormat, Vec), CliError> { - let format = requested - .map(FormatArg::structure_format) - .or_else(|| detect_format(path)) - .ok_or_else(|| CliError::StructureFormat(path.to_owned()))?; - let mut bytes = Vec::new(); - if path == Path::new("-") { - io::stdin() - .read_to_end(&mut bytes) - .map_err(|source| CliError::StructureRead { - path: path.to_owned(), - source, - })?; - } else { - File::open(path) - .and_then(|mut file| file.read_to_end(&mut bytes)) - .map_err(|source| CliError::StructureRead { - path: path.to_owned(), - source, - })?; - } - Ok((format, bytes)) -} - -/// Parses bytes with the reader selected by the resolved format. -fn parse_structure(format: StructureFormat, path: &Path, bytes: &[u8]) -> Result { - let result = match format { - StructureFormat::Pdb => PdbParser::new().parse_bytes(bytes).map_err(|source| source.to_string()), - StructureFormat::Mmcif => MmcifParser::new() - .parse_bytes(bytes) - .map_err(|source| source.to_string()), - }; - result.map_err(|message| CliError::StructureParse { - path: path.to_owned(), - message, - }) -} - -/// Prints a structure summary in text or JSON form. -fn print_inspection( - path: &Path, - format: StructureFormat, - byte_count: usize, - parsed: &StructureParseResult, - output: OutputArg, - verbose: bool, -) -> Result<(), CliError> { - match output { - OutputArg::Text => print_text_summary(path, format, byte_count, parsed, verbose), - OutputArg::Json => print_json_summary(path, format, byte_count, parsed, verbose), - } -} - -/// Verifies structure invariants and reports the result. -fn validate_structure( - path: &Path, - format: StructureFormat, - parsed: &StructureParseResult, - output: OutputArg, -) -> Result<(), CliError> { - let validation = parsed - .structure - .validate_invariants() - .map_err(|error| error.to_string()) - .and_then(|()| validate_content(&parsed.structure)); - match output { - OutputArg::Text => { - if let Err(message) = validation { - let error = Style::new().red().bold(); - eprintln!("{} {}", error.apply_to("✗ Invalid structure:"), message); - return Err(CliError::StructureValidation { - path: path.to_owned(), - message, - }); - } - let success = Style::new().green().bold(); - println!( - "{} {} ({})", - success.apply_to("✓ Valid structure:"), - path.display(), - format.label() - ); - } - OutputArg::Json => { - let valid = validation.is_ok(); - let value = json!({ - "path": path.display().to_string(), - "format": format.id(), - "valid": valid, - "error": validation.err(), - }); - println!("{}", serde_json::to_string_pretty(&value)?); - if let Some(message) = value.get("error").and_then(Value::as_str) { - return Err(CliError::StructureValidation { - path: path.to_owned(), - message: message.to_owned(), - }); - } - } - } - Ok(()) -} -/// Verifies that the parsed value contains an actual coordinate-bearing -/// structure rather than only metadata or ignored records. +/// Writes a shared command outcome with process-terminal styling. /// /// # Parameters /// -/// * `structure` is the parsed, index-validated structure snapshot. +/// * `outcome` is the frontend-independent result returned by the executor. /// /// # Returns /// -/// `Ok(())` when at least one atom and one finite coordinate are present; -/// otherwise returns the reason the input cannot be considered a valid -/// molecular structure. -fn validate_content(structure: &Structure) -> Result<(), String> { - if structure.atoms().is_empty() { - return Err("no atom records were found".to_owned()); - } - if structure.models().is_empty() { - return Err("no coordinate models were found".to_owned()); - } - if !structure - .coordinates() - .iter() - .flat_map(|coordinates| coordinates.positions.iter()) - .any(|position| position.iter().all(|coordinate| coordinate.is_finite())) - { - return Err("no finite atom coordinates were found".to_owned()); - } - Ok(()) -} - -/// Prints the human-readable inspection summary with terminal color accents. -fn print_text_summary( - path: &Path, - format: StructureFormat, - byte_count: usize, - parsed: &StructureParseResult, - verbose: bool, -) -> Result<(), CliError> { - let heading = Style::new().cyan().bold(); - let label = Style::new().dim(); - let value = Style::new().green().bold(); - let warning = Style::new().yellow(); - println!("{} {}", heading.apply_to("Structure"), path.display()); - println!("{} {}", label.apply_to("Format:"), value.apply_to(format.label())); - println!("{} {}", label.apply_to("Bytes:"), byte_count); - println!(); - for (name, count) in summary_counts(&parsed.structure) { - println!("{} {}", label.apply_to(format!("{name}:")), value.apply_to(count)); - } - if !parsed.diagnostics.is_empty() { - println!("{} {}", warning.apply_to("Diagnostics:"), parsed.diagnostics.len()); - } - if verbose { - println!(); - println!("{}", heading.apply_to("Details")); - println!("{} {:#?}", label.apply_to("Metadata:"), parsed.structure.metadata()); - println!( - "{} {:?}", - label.apply_to("Chains:"), - parsed - .structure - .chains() - .iter() - .map(|chain| chain.auth_id.as_deref().or(chain.label_id.as_deref())) - .collect::>() - ); - if !parsed.diagnostics.is_empty() { - println!("{} {:#?}", warning.apply_to("Diagnostics:"), parsed.diagnostics); +/// The domain-level completion status used as the process exit status. +pub(crate) fn render_outcome(outcome: &CommandOutcome) -> CommandReportStatus { + let report = render_command_outcome(outcome); + for line in &report.lines { + for span in &line.spans { + print!("{}", style(span.tone).apply_to(&span.text)); } + println!(); } - Ok(()) -} - -/// Prints the stable machine-readable inspection summary. -fn print_json_summary( - path: &Path, - format: StructureFormat, - byte_count: usize, - parsed: &StructureParseResult, - verbose: bool, -) -> Result<(), CliError> { - let mut value = summary_value(path, format, byte_count, &parsed.structure, parsed.diagnostics.len()); - if verbose { - value["metadata"] = metadata_value(&parsed.structure); - value["diagnostics_detail"] = json!( - parsed - .diagnostics - .iter() - .map(|diagnostic| json!({ - "code": diagnostic.code, - "line": diagnostic.line, - "severity": format!("{:?}", diagnostic.severity), - "message": diagnostic.message, - })) - .collect::>() - ); - } - println!("{}", serde_json::to_string_pretty(&value)?); - Ok(()) -} - -/// Builds a stable JSON representation of the currently parsed metadata. -fn metadata_value(structure: &Structure) -> Value { - json!({ - "classification": structure.metadata().classification, - "identifier": structure.metadata().identifier, - "unit_cell": structure.metadata().unit_cell.as_ref().map(|cell| json!({ - "lengths": cell.lengths, - "angles": cell.angles, - })), - "symmetry": structure.metadata().symmetry.as_ref().map(|symmetry| json!({ - "space_group_name": symmetry.space_group_name, - "international_tables_number": symmetry.international_tables_number, - })), - "assembly": { - "operations": structure.metadata().assembly.operations.iter().map(|operation| json!({ - "id": operation.id, - "rotation": operation.rotation, - "translation": operation.translation, - })).collect::>(), - "assemblies": structure.metadata().assembly.assemblies.iter().map(|assembly| json!({ - "id": assembly.id, - "details": assembly.details, - "generations": assembly.generations.iter().map(|generation| json!({ - "asym_ids": generation.asym_ids, - "auth_asym_ids": generation.auth_asym_ids, - "entity_instance_ids": generation.entity_instance_ids, - "operator_expression": generation.operator_expression, - })).collect::>(), - })).collect::>(), - }, - }) -} - -/// Returns the stable count fields shared by text and JSON summaries. -fn summary_counts(structure: &Structure) -> Vec<(&'static str, usize)> { - vec![ - ("Models", structure.models().len()), - ("Chains", structure.chains().len()), - ("Residues", structure.residues().len()), - ("Atoms", structure.atoms().len()), - ("Bonds", structure.bonds().len()), - ("Polymer entities", structure.polymer_entities().len()), - ("Missing polymer residues", structure.missing_polymer_residues().len()), - ("Secondary ranges", structure.secondary_ranges().len()), - ("Assembly operations", structure.metadata().assembly.operations.len()), - ("Biological assemblies", structure.metadata().assembly.assemblies.len()), - ] -} - -/// Builds the JSON object used by `inspect --output json`. -fn summary_value( - path: &Path, - format: StructureFormat, - byte_count: usize, - structure: &Structure, - diagnostics: usize, -) -> Value { - let counts = summary_counts(structure); - let mut value = json!({ - "path": path.display().to_string(), - "format": format.id(), - "bytes": byte_count, - "diagnostics": diagnostics, - }); - for (name, count) in counts { - value[name.to_ascii_lowercase().replace(' ', "_")] = json!(count); - } - value + report.status } -/// Infers a structure format from a supported filename extension. -fn detect_format(path: &Path) -> Option { - let extension = path.extension()?.to_str()?.to_ascii_lowercase(); - match extension.as_str() { - "pdb" | "ent" => Some(StructureFormat::Pdb), - "cif" | "mmcif" => Some(StructureFormat::Mmcif), - _ => None, +/// Maps a frontend-neutral output tone to ANSI terminal styling. +fn style(tone: CommandOutputTone) -> Style { + match tone { + CommandOutputTone::Primary => Style::new(), + CommandOutputTone::Secondary => Style::new().dim(), + CommandOutputTone::Heading => Style::new().cyan().bold(), + CommandOutputTone::Success => Style::new().green().bold(), + CommandOutputTone::Warning => Style::new().yellow(), + CommandOutputTone::Error => Style::new().red().bold(), } } diff --git a/crates/chitin-command/Cargo.toml b/crates/chitin-command/Cargo.toml index 1306d249..cf373a2f 100644 --- a/crates/chitin-command/Cargo.toml +++ b/crates/chitin-command/Cargo.toml @@ -5,5 +5,15 @@ edition.workspace = true license.workspace = true repository.workspace = true +[dependencies] +chitin-bio = { path = "../chitin-bio" } +chitin-databases = { path = "../chitin-databases" } +clap = { version = "4.6", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" + +[dev-dependencies] +tokio = { version = "1.52", features = ["macros", "rt"] } + [lints] workspace = true diff --git a/crates/chitin-command/src/application.rs b/crates/chitin-command/src/application.rs deleted file mode 100644 index f8328f2e..00000000 --- a/crates/chitin-command/src/application.rs +++ /dev/null @@ -1,28 +0,0 @@ -/// Application-shell commands. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ApplicationCommand { - /// Show or hide the command panel. - ToggleCommandPanel, -} - -impl ApplicationCommand { - /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { - match self { - Self::ToggleCommandPanel => "application.toggle_command_panel", - } - } -} - -/// Returns the application command descriptors. -pub fn command_descriptors() -> Vec { - vec![crate::CommandDescriptor { - id: ApplicationCommand::ToggleCommandPanel.id(), - title: "Toggle Command Panel", - category: crate::CommandCategory::Application, - keywords: &["quick pick", "palette", "commands"], - shortcut: Some("Ctrl/Cmd+Shift+P"), - invocation: crate::CommandInvocationKind::Immediate, - command: ApplicationCommand::ToggleCommandPanel.into(), - }] -} diff --git a/crates/chitin-command/src/database.rs b/crates/chitin-command/src/database.rs deleted file mode 100644 index 24a434bf..00000000 --- a/crates/chitin-command/src/database.rs +++ /dev/null @@ -1,28 +0,0 @@ -/// Database-provider commands. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum DatabaseCommand { - /// Open the RCSB structure download workflow. - DownloadRcsbStructure, -} - -impl DatabaseCommand { - /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { - match self { - Self::DownloadRcsbStructure => "database.rcsb.download_structure", - } - } -} - -/// Returns the database command descriptors. -pub fn command_descriptors() -> Vec { - vec![crate::CommandDescriptor { - id: DatabaseCommand::DownloadRcsbStructure.id(), - title: "Download RCSB Structure", - category: crate::CommandCategory::Database, - keywords: &["pdb", "rcsb", "mmcif", "structure"], - shortcut: None, - invocation: crate::CommandInvocationKind::Form, - command: DatabaseCommand::DownloadRcsbStructure.into(), - }] -} diff --git a/crates/chitin-command/src/execution/context.rs b/crates/chitin-command/src/execution/context.rs new file mode 100644 index 00000000..4afb6b21 --- /dev/null +++ b/crates/chitin-command/src/execution/context.rs @@ -0,0 +1,138 @@ +//! Frontend-neutral execution context and observable command events. + +use std::{path::PathBuf, sync::Arc}; + +use chitin_databases::{CancellationToken, PersistedArtifact}; + +use crate::CommandId; + +/// Runtime resources supplied by the frontend executing a command. +#[derive(Clone, Debug)] +pub struct CommandExecutionContext { + /// Directory used to resolve relative input and output paths. + pub working_directory: PathBuf, + /// Active project root when the command runs inside a workspace. + pub workspace_root: Option, + /// Default `.chitin/download` directory for downloads without `--output`. + pub default_download_root: Option, + /// Bytes supplied for an input path of `-`. + pub standard_input: Option>, + /// Cooperative cancellation shared with the frontend task owner. + pub cancellation: CancellationToken, +} + +impl CommandExecutionContext { + /// Creates an execution context rooted at the supplied working directory. + pub fn new(working_directory: impl Into) -> Self { + Self { + working_directory: working_directory.into(), + workspace_root: None, + default_download_root: None, + standard_input: None, + cancellation: CancellationToken::new(), + } + } + + /// Sets the active workspace root. + pub fn with_workspace_root(mut self, workspace_root: impl Into) -> Self { + self.workspace_root = Some(workspace_root.into()); + self + } + + /// Sets the default directory containing format-specific download folders. + pub fn with_default_download_root(mut self, root: impl Into) -> Self { + self.default_download_root = Some(root.into()); + self + } + + /// Supplies standard-input bytes for commands whose input path is `-`. + pub fn with_standard_input(mut self, bytes: impl Into>) -> Self { + self.standard_input = Some(bytes.into()); + self + } + + /// Replaces the cooperative cancellation token. + pub fn with_cancellation(mut self, cancellation: CancellationToken) -> Self { + self.cancellation = cancellation; + self + } +} + +/// Progress for the active stage of a command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandProgress { + /// Completed units for the active stage. + pub completed: u64, + /// Total units when the executor can determine them. + pub total: Option, + /// One-based active stage index. + pub stage_index: usize, + /// Number of stages in the command. + pub stage_count: usize, + /// Human-readable active stage label. + pub stage_label: Option, +} + +/// Severity attached to a command diagnostic message. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandMessageLevel { + /// Normal execution information. + Info, + /// Recoverable condition worth presenting to the user. + Warning, +} + +/// Structured diagnostic emitted during command execution. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandMessage { + /// Diagnostic severity. + pub level: CommandMessageLevel, + /// User-facing message without terminal or GPUI styling. + pub text: String, +} + +/// Observable event emitted by the frontend-independent executor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommandExecutionEvent { + /// Command execution has started. + Started { + /// Stable identity of the executing command. + command_id: CommandId, + }, + /// Progress changed for the active stage. + Progress(CommandProgress), + /// A diagnostic message was produced. + Message(CommandMessage), + /// A downloaded artifact was durably persisted. + Artifact(Box), + /// Command execution completed successfully. + Completed { + /// Stable identity of the completed command. + command_id: CommandId, + }, +} + +/// Cloneable event callback shared with asynchronous provider operations. +#[derive(Clone)] +pub struct CommandEventSink { + callback: Arc, +} + +impl CommandEventSink { + /// Creates an event sink from a thread-safe callback. + pub fn new(callback: impl Fn(CommandExecutionEvent) + Send + Sync + 'static) -> Self { + Self { + callback: Arc::new(callback), + } + } + + /// Emits one event to the frontend adapter. + pub fn emit(&self, event: CommandExecutionEvent) { + (self.callback)(event); + } + + /// Creates a sink that deliberately discards all events. + pub fn silent() -> Self { + Self::new(|_| {}) + } +} diff --git a/crates/chitin-command/src/execution/database.rs b/crates/chitin-command/src/execution/database.rs new file mode 100644 index 00000000..ff3c17ea --- /dev/null +++ b/crates/chitin-command/src/execution/database.rs @@ -0,0 +1,279 @@ +//! Portable database-command execution. + +use std::{collections::BTreeSet, path::PathBuf}; + +use crate::{ + CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandId, CommandMessage, CommandMessageLevel, + CommandProgress, DatabaseCommand, RcsbDownloadArguments, +}; +use chitin_databases::{ + Client, + providers::rcsb::{RcsbBatchDownloadEvent, RcsbBatchDownloadRequest}, +}; + +use super::{CommandExecutionError, CommandOutcome}; + +/// Executes a portable database command through the shared provider client. +/// +/// # Parameters +/// +/// * `client` owns shared transport, retry, and concurrency policy. +/// * `command` contains validated provider-specific arguments. +/// * `context` supplies path resolution and cancellation resources. +/// * `events` receives frontend-neutral progress and artifacts. +/// +/// # Returns +/// +/// The typed outcome of the selected database workflow. +pub(super) async fn execute( + client: &Client, + command: DatabaseCommand, + context: &CommandExecutionContext, + events: &CommandEventSink, +) -> Result { + match command { + DatabaseCommand::DownloadRcsbStructure(arguments) => { + execute_rcsb_download(client, arguments, context, events).await + } + } +} + +/// Resolves the unique destination paths produced by an RCSB download. +/// +/// # Parameters +/// +/// * `arguments` contains identifiers, format, and an optional output override. +/// * `context` supplies the working directory and default download root. +/// +/// # Returns +/// +/// Final artifact paths in input order with duplicate destinations removed. +pub fn resolve_rcsb_download_paths( + arguments: &RcsbDownloadArguments, + context: &CommandExecutionContext, +) -> Result, CommandExecutionError> { + let multiple = arguments.ids.len() > 1; + let mut destinations = BTreeSet::new(); + arguments + .ids + .iter() + .map(|id| { + let destination = match arguments.output.as_ref() { + Some(output) => { + let output = resolve_from_working_directory(output, &context.working_directory); + if output.is_dir() { + output.join(arguments.format.filename(id)) + } else if multiple && (output.extension().is_some() || output.exists()) { + return Err(CommandExecutionError::MultipleOutputFile(output)); + } else if multiple { + output.join(arguments.format.filename(id)) + } else { + output + } + } + None => context + .default_download_root + .as_ref() + .ok_or(CommandExecutionError::MissingDownloadRoot { + command_id: CommandId::DatabaseDownloadRcsbStructure, + }) + .map(|root| resolve_from_working_directory(root, &context.working_directory))? + .join(arguments.format.id()) + .join(arguments.format.filename(id)), + }; + Ok(destination) + }) + .filter_map(|result| match result { + Ok(path) if destinations.insert(path.clone()) => Some(Ok(path)), + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect() +} + +/// Downloads all unique RCSB artifacts and forwards provider progress. +/// +/// # Parameters +/// +/// * `client` provides the configured RCSB provider. +/// * `arguments` contains validated identifiers and output preferences. +/// * `context` supplies path defaults and cooperative cancellation. +/// * `events` receives normalized progress, messages, and artifacts. +/// +/// # Returns +/// +/// A database outcome containing the persisted artifact count. +async fn execute_rcsb_download( + client: &Client, + arguments: RcsbDownloadArguments, + context: &CommandExecutionContext, + events: &CommandEventSink, +) -> Result { + let destinations = resolve_rcsb_download_paths(&arguments, context)?; + let mut seen = BTreeSet::new(); + let requests = arguments + .ids + .into_iter() + .filter(|id| seen.insert(id.clone())) + .zip(destinations) + .map(|(id, destination)| RcsbBatchDownloadRequest { + id, + format: arguments.format, + destination, + }) + .collect::>(); + let artifact_count = requests.len(); + let event_sink = events.clone(); + client + .rcsb() + .download_structures_to_paths_with_cancellation( + &requests, + move |event| emit_download_event(&event_sink, event), + context.cancellation.clone(), + ) + .await?; + Ok(CommandOutcome::DatabaseDownload { artifact_count }) +} + +/// Translates provider-specific download events into the command protocol. +/// +/// # Parameters +/// +/// * `events` is the frontend observer receiving normalized events. +/// * `event` is the provider-specific update being projected. +/// +/// # Returns +/// +/// This function returns after synchronously forwarding the event. +fn emit_download_event(events: &CommandEventSink, event: RcsbBatchDownloadEvent) { + match event { + RcsbBatchDownloadEvent::Started { index, total, id } => { + events.emit(CommandExecutionEvent::Progress(CommandProgress { + completed: 0, + total: None, + stage_index: index, + stage_count: total, + stage_label: Some(id.to_string()), + })); + } + RcsbBatchDownloadEvent::Progress { + index, + total, + id, + received, + total_bytes, + } => { + events.emit(CommandExecutionEvent::Progress(CommandProgress { + completed: received, + total: total_bytes, + stage_index: index, + stage_count: total, + stage_label: Some(id.to_string()), + })); + } + RcsbBatchDownloadEvent::Completed { id, artifact, .. } => { + events.emit(CommandExecutionEvent::Message(CommandMessage { + level: CommandMessageLevel::Info, + text: format!("downloaded RCSB structure {id}"), + })); + events.emit(CommandExecutionEvent::Artifact(artifact)); + } + } +} + +/// Resolves a relative path against the frontend working directory. +fn resolve_from_working_directory(path: &std::path::Path, working_directory: &std::path::Path) -> PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + working_directory.join(path) + } +} + +#[cfg(test)] +mod tests { + use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; + + use super::*; + + fn id(value: &str) -> Result> { + Ok(PdbId::new(value)?) + } + + #[test] + fn default_download_paths_should_use_format_directory() -> Result<(), Box> { + let arguments = RcsbDownloadArguments { + ids: vec![id("4hhb")?], + format: StructureFormat::Mmcif, + output: None, + }; + let context = CommandExecutionContext::new("/workspace").with_default_download_root("/workspace/.chitin/download"); + + let paths = resolve_rcsb_download_paths(&arguments, &context)?; + + assert_eq!(paths, [PathBuf::from("/workspace/.chitin/download/mmcif/4HHB.cif")]); + Ok(()) + } + + #[test] + fn repeated_ids_should_produce_one_destination() -> Result<(), Box> { + let repeated = id("4hhb")?; + let arguments = RcsbDownloadArguments { + ids: vec![repeated.clone(), repeated], + format: StructureFormat::Pdb, + output: None, + }; + let context = CommandExecutionContext::new("/workspace").with_default_download_root("/workspace/.chitin/download"); + + let paths = resolve_rcsb_download_paths(&arguments, &context)?; + + assert_eq!(paths.len(), 1); + Ok(()) + } + + #[test] + fn existing_directory_should_receive_generated_filename() -> Result<(), Box> { + let directory = std::env::temp_dir(); + let arguments = RcsbDownloadArguments { + ids: vec![id("1yth")?], + format: StructureFormat::Pdb, + output: Some(directory.clone()), + }; + let context = CommandExecutionContext::new("/workspace"); + + let paths = resolve_rcsb_download_paths(&arguments, &context)?; + + assert_eq!(paths, [directory.join("1YTH.pdb")]); + Ok(()) + } + + #[test] + fn nonexistent_extensionless_single_output_should_remain_a_file() -> Result<(), Box> { + let arguments = RcsbDownloadArguments { + ids: vec![id("1yth")?], + format: StructureFormat::Pdb, + output: Some(PathBuf::from("custom/structure")), + }; + let context = CommandExecutionContext::new("/workspace"); + + let paths = resolve_rcsb_download_paths(&arguments, &context)?; + + assert_eq!(paths, [PathBuf::from("/workspace/custom/structure")]); + Ok(()) + } + + #[test] + fn multiple_downloads_should_reject_an_explicit_file() -> Result<(), Box> { + let arguments = RcsbDownloadArguments { + ids: vec![id("1yth")?, id("4hhb")?], + format: StructureFormat::Mmcif, + output: Some(PathBuf::from("structures/output.cif")), + }; + let context = CommandExecutionContext::new("/workspace"); + + let result = resolve_rcsb_download_paths(&arguments, &context); + + assert!(matches!(result, Err(CommandExecutionError::MultipleOutputFile(_)))); + Ok(()) + } +} diff --git a/crates/chitin-command/src/execution/mod.rs b/crates/chitin-command/src/execution/mod.rs new file mode 100644 index 00000000..c294d7b2 --- /dev/null +++ b/crates/chitin-command/src/execution/mod.rs @@ -0,0 +1,229 @@ +//! Frontend-independent execution for portable Chitin commands. +//! +//! Frontends supply an execution context, observe structured events, and +//! convert the returned outcome through [`crate::output`] without embedding +//! CLI or GPUI behavior in the executor. + +mod context; +mod database; +mod structure; + +use std::{ + path::PathBuf, + sync::{Arc, OnceLock}, +}; + +use chitin_bio::structure::StructureParseResult; +use chitin_databases::{Client, ClientConfig, TransportError, providers::rcsb::StructureFormat}; + +pub use context::{ + CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandMessage, CommandMessageLevel, + CommandProgress, +}; +pub use database::resolve_rcsb_download_paths; + +use crate::{CommandId, CommandOutputFormat, PortableCommand}; + +/// Successful result returned by a portable command. +#[derive(Clone, Debug, PartialEq)] +pub enum CommandOutcome { + /// One or more RCSB artifacts were downloaded. + DatabaseDownload { + /// Number of unique persisted artifacts. + artifact_count: usize, + }, + /// A structure was parsed for inspection. + StructureInspection(Box), + /// A structure was parsed and checked for model validity. + StructureValidation(StructureValidation), +} + +/// Parsed structure data and presentation preferences for inspection output. +#[derive(Clone, Debug, PartialEq)] +pub struct StructureInspection { + /// User-provided source path, or `-` for standard input. + pub path: PathBuf, + /// Resolved source format. + pub format: StructureFormat, + /// Number of source bytes parsed. + pub byte_count: usize, + /// Shared structure model and recoverable diagnostics. + pub parsed: StructureParseResult, + /// Output representation requested by the frontend. + pub output: CommandOutputFormat, + /// Whether detailed metadata and diagnostics were requested. + pub verbose: bool, +} + +/// Result of parsing and validating one local structure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructureValidation { + /// User-provided source path, or `-` for standard input. + pub path: PathBuf, + /// Resolved source format. + pub format: StructureFormat, + /// Output representation requested by the frontend. + pub output: CommandOutputFormat, + /// Validation failure, or `None` when the structure is valid. + pub error: Option, +} + +impl StructureValidation { + /// Returns whether parsing and all model invariants succeeded. + pub fn is_valid(&self) -> bool { + self.error.is_none() + } +} + +/// Failure while preparing or executing a portable command. +#[derive(Debug, thiserror::Error)] +pub enum CommandExecutionError { + /// No default download directory was supplied for a command without output. + #[error("command '{command_id}' requires a default download directory")] + MissingDownloadRoot { + /// Command requiring the missing directory. + command_id: CommandId, + }, + /// Multiple downloads cannot target one explicit file. + #[error("--output must be a directory when downloading multiple PDB IDs: {0}")] + MultipleOutputFile(PathBuf), + /// Standard input was requested but the frontend supplied no bytes. + #[error("structure input '-' requires standard-input bytes from the frontend")] + MissingStandardInput, + /// The structure format could not be inferred. + #[error("could not determine structure format for '{0}'; use --format pdb or --format mmcif")] + UnknownStructureFormat(PathBuf), + /// A local structure file could not be read. + #[error("failed to read structure '{path}': {source}")] + StructureRead { + /// User-provided structure path. + path: PathBuf, + /// Filesystem failure. + source: std::io::Error, + }, + /// A structure parser rejected the source bytes. + #[error("failed to parse structure '{path}': {message}")] + StructureParse { + /// User-provided structure path. + path: PathBuf, + /// Parser diagnostic. + message: String, + }, + /// The shared database client could not be initialized. + #[error("failed to initialize database client: {0}")] + DatabaseClient(#[source] TransportError), + /// An RCSB provider or persistence operation failed. + #[error("RCSB download failed: {0}")] + RcsbDownload(#[from] chitin_databases::providers::rcsb::RcsbDownloadError), +} + +/// Executor for commands whose behavior is shared by every frontend. +#[derive(Clone)] +pub struct CommandExecutor { + config: ClientConfig, + client: Arc>>, +} + +impl CommandExecutor { + /// Creates an executor that initializes its database client on first use. + pub fn new(config: ClientConfig) -> Self { + Self { + config, + client: Arc::new(OnceLock::new()), + } + } + + /// Executes a complete typed command and emits frontend-neutral events. + /// + /// # Parameters + /// + /// * `command` contains the validated command identity and arguments. + /// * `context` supplies paths, standard input, and cancellation. + /// * `events` receives progress, diagnostics, and persisted artifacts. + /// + /// # Returns + /// + /// A typed outcome that the calling frontend can render or consume. + /// + /// # Errors + /// + /// Returns [`CommandExecutionError`] when required context is missing, + /// parsing fails, or provider execution fails. + pub async fn execute( + &self, + command: PortableCommand, + context: CommandExecutionContext, + events: CommandEventSink, + ) -> Result { + let command_id = command.id(); + events.emit(CommandExecutionEvent::Started { command_id }); + let outcome = match command { + PortableCommand::Database(command) => { + database::execute(self.database_client()?, command, &context, &events).await + } + PortableCommand::Structure(command) => structure::execute(command, &context), + }?; + events.emit(CommandExecutionEvent::Completed { command_id }); + Ok(outcome) + } + + /// Returns the lazily initialized shared database client. + fn database_client(&self) -> Result<&Client, CommandExecutionError> { + match self.client.get_or_init(|| Client::new(self.config.clone())) { + Ok(client) => Ok(client), + Err(error) => Err(CommandExecutionError::DatabaseClient(error.clone())), + } + } +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, sync::Mutex}; + + use chitin_databases::providers::rcsb::StructureFormat; + + use super::*; + use crate::{StructureCommand, StructureInputArguments, StructureInspectArguments}; + + #[tokio::test] + async fn executor_should_emit_lifecycle_events_around_structure_inspection() -> Result<(), CommandExecutionError> { + let observed = Arc::new(Mutex::new(Vec::new())); + let callback_observed = observed.clone(); + let events = CommandEventSink::new(move |event| { + if let Ok(mut observed) = callback_observed.lock() { + observed.push(event); + } + }); + let command = StructureCommand::Inspect(StructureInspectArguments { + input: StructureInputArguments { + input: PathBuf::from("-"), + format: Some(StructureFormat::Pdb), + }, + output: CommandOutputFormat::Text, + verbose: false, + }); + let context = CommandExecutionContext::new(".").with_standard_input( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), + ); + let executor = CommandExecutor::new(ClientConfig::default()); + + let _ = executor.execute(command.into(), context, events).await?; + let observed = match observed.lock() { + Ok(observed) => observed.clone(), + Err(_) => Vec::new(), + }; + + assert!(matches!( + observed.as_slice(), + [ + CommandExecutionEvent::Started { + command_id: CommandId::StructureInspect + }, + CommandExecutionEvent::Completed { + command_id: CommandId::StructureInspect + } + ] + )); + Ok(()) + } +} diff --git a/crates/chitin-command/src/execution/structure.rs b/crates/chitin-command/src/execution/structure.rs new file mode 100644 index 00000000..51804435 --- /dev/null +++ b/crates/chitin-command/src/execution/structure.rs @@ -0,0 +1,188 @@ +//! Portable local-structure inspection and validation. + +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use chitin_bio::structure::{MmcifParser, PdbParser, Structure, StructureParseResult}; +use chitin_databases::providers::rcsb::StructureFormat; + +use super::{CommandExecutionError, CommandOutcome, StructureInspection, StructureValidation}; +use crate::{CommandExecutionContext, StructureCommand, StructureInputArguments}; + +/// Executes a local structure command against frontend-provided resources. +/// +/// # Parameters +/// +/// * `command` contains the input, output, and validation preferences. +/// * `context` supplies the working directory and optional standard input. +/// +/// # Returns +/// +/// Parsed inspection data or a structured validation result. +pub(super) fn execute( + command: StructureCommand, + context: &CommandExecutionContext, +) -> Result { + match command { + StructureCommand::Inspect(arguments) => { + let (format, bytes) = read_input(&arguments.input, context)?; + let parsed = parse_structure(format, &arguments.input.input, &bytes)?; + Ok(CommandOutcome::StructureInspection(Box::new(StructureInspection { + path: arguments.input.input, + format, + byte_count: bytes.len(), + parsed, + output: arguments.output, + verbose: arguments.verbose, + }))) + } + StructureCommand::Validate(arguments) => { + let (format, bytes) = read_input(&arguments.input, context)?; + let parsed = parse_structure(format, &arguments.input.input, &bytes)?; + let error = parsed + .structure + .validate_invariants() + .map_err(|error| error.to_string()) + .and_then(|()| validate_content(&parsed.structure)) + .err(); + Ok(CommandOutcome::StructureValidation(StructureValidation { + path: arguments.input.input, + format, + output: arguments.output, + error, + })) + } + } +} + +/// Reads a local file or frontend-provided standard input. +/// +/// # Parameters +/// +/// * `input` identifies the source path and optional explicit format. +/// * `context` resolves relative paths and supplies standard-input bytes. +/// +/// # Returns +/// +/// The resolved structure format and complete source bytes. +fn read_input( + input: &StructureInputArguments, + context: &CommandExecutionContext, +) -> Result<(StructureFormat, Arc<[u8]>), CommandExecutionError> { + let path = &input.input; + let format = input + .format + .or_else(|| detect_format(path)) + .ok_or_else(|| CommandExecutionError::UnknownStructureFormat(path.to_owned()))?; + if path == Path::new("-") { + let bytes = context + .standard_input + .as_ref() + .ok_or(CommandExecutionError::MissingStandardInput)? + .clone(); + return Ok((format, bytes)); + } + let resolved = resolve_from_working_directory(path, &context.working_directory); + let bytes = std::fs::read(resolved).map_err(|source| CommandExecutionError::StructureRead { + path: path.to_owned(), + source, + })?; + Ok((format, bytes.into())) +} + +/// Parses bytes with the reader selected by the resolved format. +/// +/// # Parameters +/// +/// * `format` selects the PDB or mmCIF parser. +/// * `path` identifies the input in diagnostics. +/// * `bytes` contains the complete source document. +/// +/// # Returns +/// +/// The shared molecular structure and recoverable parser diagnostics. +fn parse_structure( + format: StructureFormat, + path: &Path, + bytes: &[u8], +) -> Result { + let result = match format { + StructureFormat::Pdb => PdbParser::new().parse_bytes(bytes).map_err(|source| source.to_string()), + StructureFormat::Mmcif => MmcifParser::new() + .parse_bytes(bytes) + .map_err(|source| source.to_string()), + }; + result.map_err(|message| CommandExecutionError::StructureParse { + path: path.to_owned(), + message, + }) +} + +/// Verifies that parsed data contains a coordinate-bearing structure. +fn validate_content(structure: &Structure) -> Result<(), String> { + if structure.atoms().is_empty() { + return Err("no atom records were found".to_owned()); + } + if structure.models().is_empty() { + return Err("no coordinate models were found".to_owned()); + } + if !structure + .coordinates() + .iter() + .flat_map(|coordinates| coordinates.positions.iter()) + .any(|position| position.iter().all(|coordinate| coordinate.is_finite())) + { + return Err("no finite atom coordinates were found".to_owned()); + } + Ok(()) +} + +/// Infers a structure format from a supported filename extension. +fn detect_format(path: &Path) -> Option { + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + match extension.as_str() { + "pdb" | "ent" => Some(StructureFormat::Pdb), + "cif" | "mmcif" => Some(StructureFormat::Mmcif), + _ => None, + } +} + +/// Resolves a relative input path against the frontend working directory. +fn resolve_from_working_directory(path: &Path, working_directory: &Path) -> PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + working_directory.join(path) + } +} + +#[cfg(test)] +mod tests { + use crate::{CommandOutputFormat, StructureInspectArguments}; + + use super::*; + + #[test] + fn inspect_should_parse_frontend_standard_input() -> Result<(), CommandExecutionError> { + let command = StructureCommand::Inspect(StructureInspectArguments { + input: StructureInputArguments { + input: PathBuf::from("-"), + format: Some(StructureFormat::Pdb), + }, + output: CommandOutputFormat::Text, + verbose: false, + }); + let context = CommandExecutionContext::new(".").with_standard_input( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), + ); + + let outcome = execute(command, &context)?; + + assert!( + matches!(outcome, CommandOutcome::StructureInspection(inspection) if inspection.parsed.structure.atoms().len() == 1) + ); + Ok(()) + } +} diff --git a/crates/chitin-command/src/grammar/mod.rs b/crates/chitin-command/src/grammar/mod.rs new file mode 100644 index 00000000..f3f09eb3 --- /dev/null +++ b/crates/chitin-command/src/grammar/mod.rs @@ -0,0 +1,5 @@ +//! Clap grammar fragments shared by textual Chitin frontends. + +mod portable; + +pub use portable::{PortableCommandArgs, PortableCommandLineError}; diff --git a/crates/chitin-command/src/grammar/portable.rs b/crates/chitin-command/src/grammar/portable.rs new file mode 100644 index 00000000..29b269cb --- /dev/null +++ b/crates/chitin-command/src/grammar/portable.rs @@ -0,0 +1,220 @@ +//! Portable command fragments shared by CLI-style frontends. + +use std::path::PathBuf; + +use crate::{ + CommandOutputFormat, DatabaseCommand, PortableCommand, RcsbDownloadArguments, StructureCommand, + StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +}; +use chitin_databases::providers::rcsb::{PdbId, PdbIdListError, StructureFormat}; +use clap::{Args, Subcommand, ValueEnum}; + +/// Portable command tree embedded by the process CLI and built-in shell. +#[derive(Debug, Subcommand)] +pub enum PortableCommandArgs { + /// Work with external biological databases. + #[command(name = "db", visible_alias = "databases")] + Database(DatabaseCommandArgs), + /// Inspect or validate a local PDB/mmCIF structure file. + Structure(StructureCommandArgs), +} + +impl PortableCommandArgs { + /// Converts validated command-line arguments into a portable typed command. + pub fn into_command(self) -> Result { + match self { + Self::Database(arguments) => arguments.into_command(), + Self::Structure(arguments) => Ok(arguments.into_command()), + } + } +} + +/// Command-line conversion failure after Clap has validated the grammar. +#[derive(Debug, thiserror::Error)] +pub enum PortableCommandLineError { + /// A comma-separated identifier list contains an invalid element. + #[error(transparent)] + InvalidPdbIds(#[from] PdbIdListError), +} + +/// Database-provider command group. +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct DatabaseCommandArgs { + #[command(subcommand)] + command: DatabaseSubcommand, +} + +impl DatabaseCommandArgs { + /// Converts the selected database workflow into a portable command. + fn into_command(self) -> Result { + match self.command { + DatabaseSubcommand::Rcsb(arguments) => arguments.into_command(), + } + } +} + +/// Supported database providers. +#[derive(Debug, Subcommand)] +enum DatabaseSubcommand { + /// Download an RCSB structure file. + Rcsb(RcsbCommandArgs), +} + +/// RCSB command group. +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +struct RcsbCommandArgs { + #[command(subcommand)] + command: RcsbSubcommand, +} + +impl RcsbCommandArgs { + /// Converts the selected RCSB workflow into a portable command. + fn into_command(self) -> Result { + match self.command { + RcsbSubcommand::Download { id, format, output } => Ok( + DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: PdbId::parse_many(&id)?, + format: format.into(), + output, + }) + .into(), + ), + } + } +} + +/// RCSB provider workflows. +#[derive(Debug, Subcommand)] +enum RcsbSubcommand { + /// Download a PDB or mmCIF structure. + Download { + /// Comma-separated list of four-character PDB identifiers, such as 4HHB,1YTH. + #[arg(long, value_name = "PDB_ID")] + id: String, + /// Structure format to download. + #[arg(long, value_enum, default_value_t = StructureFormatArg::Pdb)] + format: StructureFormatArg, + /// Output file or directory. Existing directories receive a generated filename. + #[arg(short, long, value_name = "PATH")] + output: Option, + }, +} + +/// Structure command group. +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct StructureCommandArgs { + #[command(subcommand)] + command: StructureSubcommand, +} + +impl StructureCommandArgs { + /// Converts the selected structure workflow into a portable command. + fn into_command(self) -> PortableCommand { + match self.command { + StructureSubcommand::Inspect(arguments) => StructureCommand::Inspect(StructureInspectArguments { + input: arguments.input.into_command_arguments(), + output: arguments.output.into(), + verbose: arguments.verbose, + }) + .into(), + StructureSubcommand::Validate(arguments) => StructureCommand::Validate(StructureValidateArguments { + input: arguments.input.into_command_arguments(), + output: arguments.output.into(), + }) + .into(), + } + } +} + +/// Structure parsing workflows. +#[derive(Debug, Subcommand)] +enum StructureSubcommand { + /// Print a human-readable or JSON structure summary. + Inspect(StructureInspectArgs), + /// Parse a structure and verify its indexed model invariants. + Validate(StructureValidateArgs), +} + +/// Structure inspection arguments. +#[derive(Debug, Args)] +struct StructureInspectArgs { + #[command(flatten)] + input: StructureInputArgs, + /// Output representation. + #[arg(long, value_enum, default_value_t = OutputFormatArg::Text)] + output: OutputFormatArg, + /// Include chains, metadata, assembly, and diagnostics. + #[arg(long)] + verbose: bool, +} + +/// Structure validation arguments. +#[derive(Debug, Args)] +struct StructureValidateArgs { + #[command(flatten)] + input: StructureInputArgs, + /// Output representation. + #[arg(long, value_enum, default_value_t = OutputFormatArg::Text)] + output: OutputFormatArg, +} + +/// Input path and optional format shared by structure workflows. +#[derive(Debug, Args)] +struct StructureInputArgs { + /// Structure file path, or `-` to read stdin. + #[arg(value_name = "FILE")] + input: PathBuf, + /// Input format; inferred from the extension when omitted. + #[arg(long, value_enum)] + format: Option, +} + +impl StructureInputArgs { + /// Converts parsed input options into frontend-independent arguments. + fn into_command_arguments(self) -> StructureInputArguments { + StructureInputArguments { + input: self.input, + format: self.format.map(StructureFormat::from), + } + } +} + +/// Command-line spelling for supported structure formats. +#[derive(Clone, Copy, Debug, ValueEnum)] +enum StructureFormatArg { + /// Legacy PDB format. + Pdb, + /// PDBx/mmCIF format. + #[value(alias = "cif")] + Mmcif, +} + +impl From for StructureFormat { + fn from(format: StructureFormatArg) -> Self { + match format { + StructureFormatArg::Pdb => Self::Pdb, + StructureFormatArg::Mmcif => Self::Mmcif, + } + } +} + +/// Command-line spelling for structure command output formats. +#[derive(Clone, Copy, Debug, ValueEnum)] +enum OutputFormatArg { + /// Human-readable output. + Text, + /// Stable machine-readable JSON output. + Json, +} + +impl From for CommandOutputFormat { + fn from(output: OutputFormatArg) -> Self { + match output { + OutputFormatArg::Text => Self::Text, + OutputFormatArg::Json => Self::Json, + } + } +} diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index e4919031..db05b67e 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -1,254 +1,24 @@ -//! Shared command definitions and search behavior. - -mod application; -mod database; -mod panel_tab; -mod structure; -mod workspace; - -pub use application::ApplicationCommand; -pub use database::DatabaseCommand; -pub use panel_tab::PanelTabCommand; -pub use structure::StructureCommand; -pub use workspace::WorkspaceCommand; - -/// Top-level command hierarchy shared by desktop, terminal, and CLI inputs. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ChitinCommand { - /// Workspace and document-panel commands. - Workspace(WorkspaceCommand), - /// Database-provider commands. - Database(DatabaseCommand), - /// Application-shell commands. - Application(ApplicationCommand), - /// Structure parsing and inspection commands. - Structure(StructureCommand), -} - -impl ChitinCommand { - /// Returns the stable dotted identifier for this command. - pub fn id(&self) -> &'static str { - match self { - Self::Workspace(command) => command.id(), - Self::Database(command) => command.id(), - Self::Application(command) => command.id(), - Self::Structure(command) => command.id(), - } - } -} - -impl From for ChitinCommand { - fn from(command: WorkspaceCommand) -> Self { - Self::Workspace(command) - } -} - -impl From for ChitinCommand { - fn from(command: DatabaseCommand) -> Self { - Self::Database(command) - } -} - -impl From for ChitinCommand { - fn from(command: ApplicationCommand) -> Self { - Self::Application(command) - } -} - -impl From for ChitinCommand { - fn from(command: StructureCommand) -> Self { - Self::Structure(command) - } -} - -impl From for ChitinCommand { - fn from(command: PanelTabCommand) -> Self { - Self::Workspace(WorkspaceCommand::PanelTab(command)) - } -} - -/// Command category used for search grouping and presentation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommandCategory { - /// Workspace and document-panel commands. - Workspace, - /// External database commands. - Database, - /// Application-shell commands. - Application, -} - -impl CommandCategory { - /// Returns the display label for this category. - pub fn label(self) -> &'static str { - match self { - Self::Workspace => "Workspace", - Self::Database => "Database", - Self::Application => "Application", - } - } -} - -/// Describes what happens after a command is selected. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommandInvocationKind { - /// Execute immediately. - Immediate, - /// Request a command-specific interactive workflow. - Form, -} - -/// Searchable metadata for a command. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandDescriptor { - /// Stable command identifier. - pub id: &'static str, - /// User-facing title. - pub title: &'static str, - /// Search category. - pub category: CommandCategory, - /// Search keywords. - pub keywords: &'static [&'static str], - /// Optional shortcut label. - pub shortcut: Option<&'static str>, - /// Invocation behavior. - pub invocation: CommandInvocationKind, - /// Typed command payload. - pub command: ChitinCommand, -} - -/// A ranked command result. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CommandSearchResult<'a> { - /// Match score, where larger values rank first. - pub score: i32, - /// Matching descriptor. - pub descriptor: &'a CommandDescriptor, -} - -/// Registry and deterministic search implementation for command descriptors. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CommandRegistry { - commands: Vec, -} - -impl CommandRegistry { - /// Creates a registry from command descriptors. - pub fn new(commands: impl IntoIterator) -> Self { - Self { - commands: commands.into_iter().collect(), - } - } - - /// Finds a descriptor by its typed command. - pub fn descriptor_for(&self, command: &ChitinCommand) -> Option<&CommandDescriptor> { - self.commands.iter().find(|descriptor| &descriptor.command == command) - } - - /// Searches commands using deterministic scoring. - pub fn search(&self, query: &str) -> Vec> { - let query = normalize(query); - let mut results = self - .commands - .iter() - .filter_map(|descriptor| { - score_descriptor(descriptor, &query).map(|score| CommandSearchResult { score, descriptor }) - }) - .collect::>(); - results.sort_by(|left, right| { - right - .score - .cmp(&left.score) - .then_with(|| left.descriptor.title.cmp(right.descriptor.title)) - }); - results - } -} - -/// Creates the built-in registry from all shared command descriptors. -pub fn default_registry() -> CommandRegistry { - CommandRegistry::new( - workspace::command_descriptors() - .into_iter() - .chain(panel_tab::command_descriptors()) - .chain(database::command_descriptors()) - .chain(application::command_descriptors()), - ) -} - -/// Scores a command descriptor against a normalized search query. -/// -/// Exact command identifiers receive the highest score, followed by title, -/// identifier-prefix, title-prefix, and category matches. The ordering keeps -/// command IDs predictable while still allowing users to search by the label -/// shown in the command panel. -/// -/// # Parameters -/// -/// * `descriptor` is the command metadata being matched. -/// * `query` is a trimmed, lowercase search query. -/// -/// # Returns -/// -/// A score when the descriptor matches, or `None` when it should be omitted. -fn score_descriptor(descriptor: &CommandDescriptor, query: &str) -> Option { - if query.is_empty() { - return Some(1); - } - let id = normalize(descriptor.id); - let title = normalize(descriptor.title); - let category = normalize(descriptor.category.label()); - // Check stronger matches first so a broad prefix cannot hide an exact hit. - if id == query { - return Some(1000); - } - if id.starts_with(query) { - return Some(850); - } - if title.starts_with(query) { - return Some(700); - } - if title.contains(query) { - return Some(500); - } - if descriptor.keywords.iter().any(|keyword| normalize(keyword) == query) { - return Some(420); - } - if descriptor - .keywords - .iter() - .any(|keyword| normalize(keyword).contains(query)) - { - return Some(360); - } - if category == query || category.starts_with(query) { - return Some(280); - } - None -} - -/// Trims surrounding whitespace and lowercases a command-search value. -/// -/// # Parameters -/// -/// * `value` is the raw command identifier, title, category, or user query. -/// -/// # Returns -/// -/// A canonical value suitable for case-insensitive matching. -fn normalize(value: &str) -> String { - value.trim().to_ascii_lowercase() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn command_ids_should_include_scope() { - assert_eq!( - ChitinCommand::from(WorkspaceCommand::FocusNext).id(), - "workspace.focus_next_entry" - ); - } -} +#![forbid(unsafe_code)] +//! Typed commands, textual grammar, portable execution, and shared output. +//! +//! Chitin frontends construct the same typed commands, execute portable work +//! through [`execution`], and render canonical reports from [`output`]. The +//! crate deliberately contains no process-terminal, GPUI, or shell-session +//! state. + +pub mod execution; +pub mod grammar; +pub mod model; +pub mod output; + +pub use execution::{ + CommandEventSink, CommandExecutionContext, CommandExecutionError, CommandExecutionEvent, CommandExecutor, + CommandMessage, CommandMessageLevel, CommandOutcome, CommandProgress, StructureInspection, StructureValidation, + resolve_rcsb_download_paths, +}; +pub use grammar::{PortableCommandArgs, PortableCommandLineError}; +pub use model::*; +pub use output::{ + CommandOutputKind, CommandOutputLine, CommandOutputSpan, CommandOutputTone, CommandReport, CommandReportStatus, + render_outcome, +}; diff --git a/crates/chitin-command/src/model/application.rs b/crates/chitin-command/src/model/application.rs new file mode 100644 index 00000000..7cf9d30e --- /dev/null +++ b/crates/chitin-command/src/model/application.rs @@ -0,0 +1,18 @@ +/// Application-shell commands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApplicationCommand { + /// Show or hide the command panel. + ToggleCommandPanel, + /// Show or hide the built-in terminal panel. + ToggleTerminal, +} + +impl ApplicationCommand { + /// Returns the stable command identifier. + pub fn id(&self) -> crate::CommandId { + match self { + Self::ToggleCommandPanel => crate::CommandId::ApplicationToggleCommandPanel, + Self::ToggleTerminal => crate::CommandId::ApplicationToggleTerminal, + } + } +} diff --git a/crates/chitin-command/src/model/catalog.rs b/crates/chitin-command/src/model/catalog.rs new file mode 100644 index 00000000..20e81921 --- /dev/null +++ b/crates/chitin-command/src/model/catalog.rs @@ -0,0 +1,454 @@ +//! Canonical command metadata and command-panel search. + +use super::{ApplicationCommand, FrontendCommand, PanelTabCommand, WorkspaceCommand}; + +/// Execution boundary required by a typed command. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CommandExecutionDomain { + /// The command can execute without application or window state. + Portable, + /// The command requires state owned by its graphical frontend. + Frontend, +} + +/// Command category used for discovery, search grouping, and presentation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandCategory { + /// Workspace and document-panel commands. + Workspace, + /// External database commands. + Database, + /// Structure parsing and analysis commands. + Structure, + /// Application-shell commands. + Application, +} + +impl CommandCategory { + /// Returns the display label for this category. + pub const fn label(self) -> &'static str { + match self { + Self::Workspace => "Workspace", + Self::Database => "Database", + Self::Structure => "Structure", + Self::Application => "Application", + } + } +} + +/// Canonical metadata for one typed command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandSpec { + /// Stable command identity. + pub id: CommandId, + /// Stable dotted name accepted by registry-driven textual frontends. + pub name: &'static str, + /// User-facing command title. + pub title: &'static str, + /// Discovery and presentation category. + pub category: CommandCategory, + /// Execution boundary required by the command. + pub execution_domain: CommandExecutionDomain, + /// Whether an input adapter must collect arguments before execution. + pub requires_arguments: bool, + /// Additional search keywords. + pub keywords: &'static [&'static str], + /// Optional shortcut label shown by discovery frontends. + pub shortcut: Option<&'static str>, + /// Whether the desktop command panel can currently invoke the command. + pub visible_in_command_panel: bool, +} + +macro_rules! frontend_command { + () => { + None + }; + ($command:expr) => { + Some(FrontendCommand::from($command)) + }; +} + +macro_rules! define_commands { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident { + name: $name:literal, + title: $title:literal, + category: $category:expr, + domain: $domain:expr, + requires_arguments: $requires_arguments:literal, + keywords: $keywords:expr, + shortcut: $shortcut:expr, + command_panel: $command_panel:literal, + frontend: [$($frontend:expr)?] + } + ),* $(,)? + ) => { + /// Stable identity of a command independent of its invocation arguments. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum CommandId { + $( + $(#[$variant_meta])* + $variant, + )* + } + + impl CommandId { + /// Every stable command identity, in declaration order. + pub const ALL: &'static [Self] = &[$(Self::$variant),*]; + + /// Returns the stable dotted identifier. + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $name),* + } + } + + /// Returns the canonical metadata for this identity. + pub const fn spec(self) -> &'static CommandSpec { + match self { + $( + Self::$variant => &CommandSpec { + id: Self::$variant, + name: $name, + title: $title, + category: $category, + execution_domain: $domain, + requires_arguments: $requires_arguments, + keywords: $keywords, + shortcut: $shortcut, + visible_in_command_panel: $command_panel, + }, + )* + } + } + + /// Builds a frontend command when this identity needs no arguments. + pub fn frontend_command_without_arguments(self) -> Option { + match self { + $(Self::$variant => frontend_command!($($frontend)?)),* + } + } + } + }; +} + +define_commands! { + /// Focus the previous project entry. + WorkspaceFocusPrevious { + name: "workspace.focus_previous_entry", + title: "Focus Previous Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["tree", "up", "previous"], + shortcut: Some("Up"), + command_panel: true, + frontend: [WorkspaceCommand::FocusPrevious] + }, + /// Focus the next project entry. + WorkspaceFocusNext { + name: "workspace.focus_next_entry", + title: "Focus Next Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["tree", "down", "next"], + shortcut: Some("Down"), + command_panel: true, + frontend: [WorkspaceCommand::FocusNext] + }, + /// Activate the focused project entry. + WorkspaceActivateFocused { + name: "workspace.activate_focused_entry", + title: "Activate Focused Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["open", "tree", "file", "directory"], + shortcut: Some("Enter"), + command_panel: true, + frontend: [WorkspaceCommand::ActivateFocused] + }, + /// Focus the first project entry. + WorkspaceFocusFirst { + name: "workspace.focus_first_entry", + title: "Focus First Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["tree", "home", "first"], + shortcut: Some("Home"), + command_panel: true, + frontend: [WorkspaceCommand::FocusFirst] + }, + /// Focus the last project entry. + WorkspaceFocusLast { + name: "workspace.focus_last_entry", + title: "Focus Last Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["tree", "end", "last"], + shortcut: Some("End"), + command_panel: true, + frontend: [WorkspaceCommand::FocusLast] + }, + /// Show or hide the workspace sidebar. + WorkspaceToggle { + name: "workspace.toggle_workspace", + title: "Toggle Workspace Sidebar", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["files", "project", "sidebar", "explorer"], + shortcut: Some("Shift+E"), + command_panel: true, + frontend: [WorkspaceCommand::ToggleWorkspace] + }, + /// Focus the previous document tab. + PanelTabFocusPrevious { + name: "tab.focus_previous", + title: "Focus Previous Tab", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["document", "panel", "tab", "previous"], + shortcut: Some("Shift+J"), + command_panel: true, + frontend: [PanelTabCommand::FocusPrevious] + }, + /// Focus the next document tab. + PanelTabFocusNext { + name: "tab.focus_next", + title: "Focus Next Tab", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["document", "panel", "tab", "next"], + shortcut: Some("Shift+K"), + command_panel: true, + frontend: [PanelTabCommand::FocusNext] + }, + /// Close the active document tab. + PanelTabClose { + name: "tab.close", + title: "Close Active Tab", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["document", "panel", "tab", "close"], + shortcut: Some("Shift+X"), + command_panel: true, + frontend: [PanelTabCommand::Close] + }, + /// Download one or more RCSB structures. + DatabaseDownloadRcsbStructure { + name: "database.rcsb.download_structure", + title: "Download RCSB Structure", + category: CommandCategory::Database, + domain: CommandExecutionDomain::Portable, + requires_arguments: true, + keywords: &["pdb", "rcsb", "mmcif", "structure"], + shortcut: None, + command_panel: true, + frontend: [] + }, + /// Show or hide the command panel. + ApplicationToggleCommandPanel { + name: "application.toggle_command_panel", + title: "Toggle Command Panel", + category: CommandCategory::Application, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["quick pick", "palette", "commands"], + shortcut: Some("Ctrl/Cmd+Shift+P"), + command_panel: true, + frontend: [ApplicationCommand::ToggleCommandPanel] + }, + /// Show or hide the built-in terminal panel. + ApplicationToggleTerminal { + name: "application.toggle_terminal", + title: "Toggle Terminal", + category: CommandCategory::Application, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["built-in shell", "console", "command line"], + shortcut: Some("Shift+T"), + command_panel: true, + frontend: [ApplicationCommand::ToggleTerminal] + }, + /// Inspect a local structure file. + StructureInspect { + name: "structure.inspect", + title: "Inspect Structure", + category: CommandCategory::Structure, + domain: CommandExecutionDomain::Portable, + requires_arguments: true, + keywords: &["pdb", "mmcif", "summary", "metadata"], + shortcut: None, + command_panel: false, + frontend: [] + }, + /// Validate a local structure file. + StructureValidate { + name: "structure.validate", + title: "Validate Structure", + category: CommandCategory::Structure, + domain: CommandExecutionDomain::Portable, + requires_arguments: true, + keywords: &["pdb", "mmcif", "check", "invariants"], + shortcut: None, + command_panel: false, + frontend: [] + }, +} + +impl std::fmt::Display for CommandId { + /// Formats the stable dotted identifier. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Iterates over every canonical command specification. +pub fn command_specs() -> impl ExactSizeIterator { + CommandId::ALL.iter().map(|id| id.spec()) +} + +/// Finds canonical command metadata by its stable dotted name. +pub fn command_spec_by_name(name: &str) -> Option<&'static CommandSpec> { + command_specs().find(|spec| spec.name == name) +} + +/// A ranked command result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CommandSearchResult { + /// Match score, where larger values rank first. + pub score: i32, + /// Matching canonical command specification. + pub spec: &'static CommandSpec, +} + +/// Registry and deterministic search implementation for command specifications. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CommandRegistry { + commands: Vec<&'static CommandSpec>, +} + +impl CommandRegistry { + /// Creates a registry from canonical command specifications. + pub fn new(commands: impl IntoIterator) -> Self { + Self { + commands: commands.into_iter().collect(), + } + } + + /// Finds a specification by stable command identity. + pub fn spec_for(&self, id: CommandId) -> Option<&'static CommandSpec> { + self.commands.iter().copied().find(|spec| spec.id == id) + } + + /// Searches commands using deterministic scoring. + pub fn search(&self, query: &str) -> Vec { + let query = normalize(query); + let mut results = self + .commands + .iter() + .filter_map(|spec| score_spec(spec, &query).map(|score| CommandSearchResult { score, spec })) + .collect::>(); + results.sort_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| left.spec.title.cmp(right.spec.title)) + }); + results + } +} + +/// Creates the registry of commands currently invocable from the desktop panel. +pub fn default_registry() -> CommandRegistry { + CommandRegistry::new(command_specs().filter(|spec| spec.visible_in_command_panel)) +} + +/// Scores a command specification against a normalized search query. +/// +/// Exact command identifiers receive the highest score, followed by title, +/// identifier-prefix, title-prefix, keyword, and category matches. This keeps +/// stable names predictable while still supporting human-facing discovery. +/// +/// # Parameters +/// +/// * `spec` is the canonical command metadata being matched. +/// * `query` is a trimmed, lowercase search query. +/// +/// # Returns +/// +/// A score when the command matches, or `None` when it should be omitted. +fn score_spec(spec: &CommandSpec, query: &str) -> Option { + if query.is_empty() { + return Some(1); + } + let name = normalize(spec.name); + let title = normalize(spec.title); + let category = normalize(spec.category.label()); + if name == query { + return Some(1000); + } + if name.starts_with(query) { + return Some(850); + } + if title.starts_with(query) { + return Some(700); + } + if title.contains(query) { + return Some(500); + } + if spec.keywords.iter().any(|keyword| normalize(keyword) == query) { + return Some(420); + } + if spec.keywords.iter().any(|keyword| normalize(keyword).contains(query)) { + return Some(360); + } + if category == query || category.starts_with(query) { + return Some(280); + } + None +} + +/// Trims surrounding whitespace and lowercases a command-search value. +fn normalize(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_specs_should_cover_every_command_id() { + assert_eq!(command_specs().count(), CommandId::ALL.len()); + } + + #[test] + fn stable_names_should_resolve_to_their_original_identity() { + for id in CommandId::ALL { + assert_eq!(command_spec_by_name(id.as_str()).map(|spec| spec.id), Some(*id)); + } + } + + #[test] + fn default_registry_should_hide_commands_without_panel_argument_ui() { + assert_eq!(default_registry().spec_for(CommandId::StructureInspect), None); + } + + #[test] + fn frontend_specs_should_construct_commands_in_the_frontend_domain() { + for spec in command_specs().filter(|spec| spec.execution_domain == CommandExecutionDomain::Frontend) { + let command = spec.id.frontend_command_without_arguments(); + assert_eq!(command.as_ref().map(FrontendCommand::id), Some(spec.id)); + } + } +} diff --git a/crates/chitin-command/src/model/database.rs b/crates/chitin-command/src/model/database.rs new file mode 100644 index 00000000..5cf95d1b --- /dev/null +++ b/crates/chitin-command/src/model/database.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; + +use crate::CommandId; + +/// Arguments for downloading one or more structures from RCSB. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RcsbDownloadArguments { + /// Canonical RCSB identifiers to download in input order. + pub ids: Vec, + /// Structure file format requested from RCSB. + pub format: StructureFormat, + /// Optional output file or directory override. + pub output: Option, +} + +/// Executable database-provider commands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DatabaseCommand { + /// Download one or more RCSB structure files. + DownloadRcsbStructure(RcsbDownloadArguments), +} + +impl DatabaseCommand { + /// Returns the stable command identifier. + pub fn id(&self) -> CommandId { + match self { + Self::DownloadRcsbStructure(_) => CommandId::DatabaseDownloadRcsbStructure, + } + } +} diff --git a/crates/chitin-command/src/model/mod.rs b/crates/chitin-command/src/model/mod.rs new file mode 100644 index 00000000..56cd327d --- /dev/null +++ b/crates/chitin-command/src/model/mod.rs @@ -0,0 +1,216 @@ +//! Typed command identities, arguments, registrations, and search behavior. + +mod application; +mod catalog; +mod database; +mod panel_tab; +mod structure; +mod workspace; + +pub use application::ApplicationCommand; +pub use catalog::*; +pub use database::{DatabaseCommand, RcsbDownloadArguments}; +pub use panel_tab::PanelTabCommand; +pub use structure::{ + CommandOutputFormat, StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +}; +pub use workspace::WorkspaceCommand; + +/// Commands executable without application or window state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PortableCommand { + /// Database-provider commands. + Database(DatabaseCommand), + /// Structure parsing and inspection commands. + Structure(StructureCommand), +} + +impl PortableCommand { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { + match self { + Self::Database(command) => command.id(), + Self::Structure(command) => command.id(), + } + } +} + +/// Commands that require state owned by a graphical frontend. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FrontendCommand { + /// Workspace and document-panel commands. + Workspace(WorkspaceCommand), + /// Application-shell commands. + Application(ApplicationCommand), +} + +impl FrontendCommand { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { + match self { + Self::Workspace(command) => command.id(), + Self::Application(command) => command.id(), + } + } +} + +/// Top-level command hierarchy shared by desktop, terminal, and CLI inputs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ChitinCommand { + /// A command executable by the frontend-independent runtime. + Portable(PortableCommand), + /// A command requiring state owned by its host frontend. + Frontend(FrontendCommand), +} + +impl ChitinCommand { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { + match self { + Self::Portable(command) => command.id(), + Self::Frontend(command) => command.id(), + } + } + + /// Returns the execution boundary required by this command. + pub fn execution_domain(&self) -> CommandExecutionDomain { + self.id().spec().execution_domain + } +} + +impl From for FrontendCommand { + fn from(command: WorkspaceCommand) -> Self { + Self::Workspace(command) + } +} + +impl From for PortableCommand { + fn from(command: DatabaseCommand) -> Self { + Self::Database(command) + } +} + +impl From for FrontendCommand { + fn from(command: ApplicationCommand) -> Self { + Self::Application(command) + } +} + +impl From for PortableCommand { + fn from(command: StructureCommand) -> Self { + Self::Structure(command) + } +} + +impl From for FrontendCommand { + fn from(command: PanelTabCommand) -> Self { + Self::Workspace(WorkspaceCommand::PanelTab(command)) + } +} + +impl From for ChitinCommand { + fn from(command: PortableCommand) -> Self { + Self::Portable(command) + } +} + +impl From for ChitinCommand { + fn from(command: FrontendCommand) -> Self { + Self::Frontend(command) + } +} + +impl From for ChitinCommand { + fn from(command: WorkspaceCommand) -> Self { + FrontendCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: DatabaseCommand) -> Self { + PortableCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: ApplicationCommand) -> Self { + FrontendCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: StructureCommand) -> Self { + PortableCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: PanelTabCommand) -> Self { + FrontendCommand::from(command).into() + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; + + use super::*; + + #[test] + fn command_ids_should_include_scope() { + assert_eq!( + ChitinCommand::from(WorkspaceCommand::FocusNext).id(), + CommandId::WorkspaceFocusNext + ); + } + + #[test] + fn database_commands_should_use_the_portable_execution_domain() -> Result<(), Box> { + let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: vec![PdbId::new("4hhb")?], + format: StructureFormat::Pdb, + output: None, + })); + + assert_eq!(command.execution_domain(), CommandExecutionDomain::Portable); + Ok(()) + } + + #[test] + fn workspace_commands_should_use_the_frontend_execution_domain() { + let command = ChitinCommand::from(WorkspaceCommand::ToggleWorkspace); + + assert_eq!(command.execution_domain(), CommandExecutionDomain::Frontend); + } + + #[test] + fn parameterized_command_id_should_not_create_an_incomplete_command() { + assert_eq!( + CommandId::DatabaseDownloadRcsbStructure.frontend_command_without_arguments(), + None + ); + } + + #[test] + fn rcsb_download_command_should_retain_validated_arguments() -> Result<(), Box> { + let id = PdbId::new("4hhb")?; + let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: vec![id.clone()], + format: StructureFormat::Mmcif, + output: Some(PathBuf::from("structures")), + })); + + assert!(matches!( + command, + ChitinCommand::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( + RcsbDownloadArguments { + ids, + format: StructureFormat::Mmcif, + output: Some(output), + }))) if ids == vec![id] && output == Path::new("structures") + )); + Ok(()) + } +} diff --git a/crates/chitin-command/src/model/panel_tab.rs b/crates/chitin-command/src/model/panel_tab.rs new file mode 100644 index 00000000..76668b2f --- /dev/null +++ b/crates/chitin-command/src/model/panel_tab.rs @@ -0,0 +1,21 @@ +/// Commands for document-panel tabs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PanelTabCommand { + /// Focus the previous tab. + FocusPrevious, + /// Focus the next tab. + FocusNext, + /// Close the active tab. + Close, +} + +impl PanelTabCommand { + /// Returns the stable command identifier. + pub fn id(&self) -> crate::CommandId { + match self { + Self::FocusPrevious => crate::CommandId::PanelTabFocusPrevious, + Self::FocusNext => crate::CommandId::PanelTabFocusNext, + Self::Close => crate::CommandId::PanelTabClose, + } + } +} diff --git a/crates/chitin-command/src/model/structure.rs b/crates/chitin-command/src/model/structure.rs new file mode 100644 index 00000000..4406abde --- /dev/null +++ b/crates/chitin-command/src/model/structure.rs @@ -0,0 +1,63 @@ +use std::path::PathBuf; + +use chitin_databases::providers::rcsb::StructureFormat; + +use crate::CommandId; + +/// Output representation requested by a command frontend. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CommandOutputFormat { + /// Human-readable text. + #[default] + Text, + /// Stable machine-readable JSON. + Json, +} + +/// Shared input arguments for local structure commands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructureInputArguments { + /// Structure file path, or `-` when the frontend supports standard input. + pub input: PathBuf, + /// Explicit input format, or `None` to infer it from the path. + pub format: Option, +} + +/// Arguments for printing a parsed structure summary. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructureInspectArguments { + /// Local structure input. + pub input: StructureInputArguments, + /// Output representation. + pub output: CommandOutputFormat, + /// Whether detailed metadata and diagnostics should be included. + pub verbose: bool, +} + +/// Arguments for validating a local structure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructureValidateArguments { + /// Local structure input. + pub input: StructureInputArguments, + /// Output representation. + pub output: CommandOutputFormat, +} + +/// Executable structure parsing and inspection commands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StructureCommand { + /// Print a parsed structure summary. + Inspect(StructureInspectArguments), + /// Parse a structure and verify its cross-table invariants. + Validate(StructureValidateArguments), +} + +impl StructureCommand { + /// Returns the stable command identifier. + pub fn id(&self) -> CommandId { + match self { + Self::Inspect(_) => CommandId::StructureInspect, + Self::Validate(_) => CommandId::StructureValidate, + } + } +} diff --git a/crates/chitin-command/src/model/workspace.rs b/crates/chitin-command/src/model/workspace.rs new file mode 100644 index 00000000..37e2f960 --- /dev/null +++ b/crates/chitin-command/src/model/workspace.rs @@ -0,0 +1,35 @@ +use crate::PanelTabCommand; + +/// Workspace and project-tree commands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WorkspaceCommand { + /// Focus the previous project entry. + FocusPrevious, + /// Focus the next project entry. + FocusNext, + /// Activate the focused project entry. + ActivateFocused, + /// Focus the first project entry. + FocusFirst, + /// Focus the last project entry. + FocusLast, + /// Show or hide the workspace sidebar. + ToggleWorkspace, + /// Execute a document-panel tab command. + PanelTab(PanelTabCommand), +} + +impl WorkspaceCommand { + /// Returns the stable command identifier. + pub fn id(&self) -> crate::CommandId { + match self { + Self::FocusPrevious => crate::CommandId::WorkspaceFocusPrevious, + Self::FocusNext => crate::CommandId::WorkspaceFocusNext, + Self::ActivateFocused => crate::CommandId::WorkspaceActivateFocused, + Self::FocusFirst => crate::CommandId::WorkspaceFocusFirst, + Self::FocusLast => crate::CommandId::WorkspaceFocusLast, + Self::ToggleWorkspace => crate::CommandId::WorkspaceToggle, + Self::PanelTab(command) => command.id(), + } + } +} diff --git a/crates/chitin-command/src/output/mod.rs b/crates/chitin-command/src/output/mod.rs new file mode 100644 index 00000000..7dc865b6 --- /dev/null +++ b/crates/chitin-command/src/output/mod.rs @@ -0,0 +1,460 @@ +//! Frontend-neutral presentation of completed command outcomes. +//! +//! This module owns wording, field order, line breaks, output format, and +//! success semantics. Process terminals and GPUI terminals only translate the +//! semantic tones into their native styling systems. + +use chitin_bio::structure::Structure; +use serde_json::{Value, json}; + +use crate::{CommandOutcome, CommandOutputFormat, StructureInspection, StructureValidation}; + +/// Completion state attached to a rendered command report. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandReportStatus { + /// The command completed successfully. + Succeeded, + /// The command completed with a domain-level failure already described by the report. + Failed, +} + +/// Serialization form selected by the command arguments. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandOutputKind { + /// Human-readable terminal output. + Text, + /// Stable machine-readable JSON output. + Json, +} + +/// Semantic visual role for one output span. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandOutputTone { + /// Normal result content. + Primary, + /// Labels and supporting text. + Secondary, + /// Section or document headings. + Heading, + /// Successful result content. + Success, + /// Recoverable diagnostic content. + Warning, + /// Failed result content. + Error, +} + +/// Styled text fragment independent of ANSI and GPUI. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandOutputSpan { + /// Human-readable content. + pub text: String, + /// Semantic role interpreted by the frontend adapter. + pub tone: CommandOutputTone, +} + +impl CommandOutputSpan { + /// Creates a semantic output span. + pub fn new(text: impl Into, tone: CommandOutputTone) -> Self { + Self { + text: text.into(), + tone, + } + } +} + +/// One physical row in a command report. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandOutputLine { + /// Ordered fragments forming the row. + pub spans: Vec, +} + +impl CommandOutputLine { + /// Creates a row from ordered semantic spans. + pub fn new(spans: impl IntoIterator) -> Self { + Self { + spans: spans.into_iter().collect(), + } + } + + /// Creates an unstyled output row. + pub fn plain(text: impl Into) -> Self { + Self::new([CommandOutputSpan::new(text, CommandOutputTone::Primary)]) + } + + /// Returns the row without frontend styling. + pub fn plain_text(&self) -> String { + self.spans.iter().map(|span| span.text.as_str()).collect() + } +} + +/// Complete portable-command output shared by textual frontends. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandReport { + /// Domain-level completion state. + pub status: CommandReportStatus, + /// Text or JSON representation selected by the command. + pub kind: CommandOutputKind, + /// Ordered physical rows without trailing newline characters. + pub lines: Vec, +} + +impl CommandReport { + /// Returns the complete report without frontend styling. + pub fn plain_text(&self) -> String { + self + .lines + .iter() + .map(CommandOutputLine::plain_text) + .collect::>() + .join("\n") + } +} + +/// Converts a completed command outcome into its canonical report. +/// +/// # Parameters +/// +/// * `outcome` contains frontend-independent command results and presentation preferences. +/// +/// # Returns +/// +/// A semantic report whose wording and line structure are identical for every frontend. +pub fn render_outcome(outcome: &CommandOutcome) -> CommandReport { + match outcome { + CommandOutcome::DatabaseDownload { artifact_count } => CommandReport { + status: CommandReportStatus::Succeeded, + kind: CommandOutputKind::Text, + lines: vec![semantic_line( + CommandOutputTone::Success, + format!("Downloaded {artifact_count} structure artifact(s)."), + )], + }, + CommandOutcome::StructureInspection(inspection) => render_inspection(inspection), + CommandOutcome::StructureValidation(validation) => render_validation(validation), + } +} + +/// Renders a structure inspection according to its requested output format. +/// +/// # Parameters +/// +/// * `inspection` contains parsed structure data and text/JSON preferences. +/// +/// # Returns +/// +/// The canonical text or JSON report for the inspection. +fn render_inspection(inspection: &StructureInspection) -> CommandReport { + match inspection.output { + CommandOutputFormat::Text => render_text_inspection(inspection), + CommandOutputFormat::Json => render_json_inspection(inspection), + } +} + +/// Renders the complete human-readable structure summary. +/// +/// # Parameters +/// +/// * `inspection` contains the parsed structure and verbosity preference. +/// +/// # Returns +/// +/// A successful report with stable labels, ordering, and optional details. +fn render_text_inspection(inspection: &StructureInspection) -> CommandReport { + let structure = &inspection.parsed.structure; + let mut lines = vec![ + paired_line( + "Structure ", + inspection.path.display().to_string(), + CommandOutputTone::Heading, + ), + paired_line("Format: ", inspection.format.label(), CommandOutputTone::Success), + paired_line("Bytes: ", inspection.byte_count.to_string(), CommandOutputTone::Primary), + CommandOutputLine::plain(""), + ]; + lines.extend( + summary_counts(structure) + .into_iter() + .map(|(label, count)| paired_line(format!("{label}: "), count.to_string(), CommandOutputTone::Success)), + ); + if !inspection.parsed.diagnostics.is_empty() { + lines.push(paired_line( + "Diagnostics: ", + inspection.parsed.diagnostics.len().to_string(), + CommandOutputTone::Warning, + )); + } + if inspection.verbose { + lines.push(CommandOutputLine::plain("")); + lines.push(semantic_line(CommandOutputTone::Heading, "Details")); + extend_multiline( + &mut lines, + &format!("Metadata: {:#?}", structure.metadata()), + CommandOutputTone::Secondary, + ); + lines.push(paired_line( + "Chains: ", + format!( + "{:?}", + structure + .chains() + .iter() + .map(|chain| chain.auth_id.as_deref().or(chain.label_id.as_deref())) + .collect::>() + ), + CommandOutputTone::Primary, + )); + if !inspection.parsed.diagnostics.is_empty() { + extend_multiline( + &mut lines, + &format!("Diagnostics: {:#?}", inspection.parsed.diagnostics), + CommandOutputTone::Warning, + ); + } + } + CommandReport { + status: CommandReportStatus::Succeeded, + kind: CommandOutputKind::Text, + lines, + } +} + +/// Renders the stable JSON structure summary. +/// +/// # Parameters +/// +/// * `inspection` contains the parsed structure and verbosity preference. +/// +/// # Returns +/// +/// A successful report whose rows contain canonical pretty-printed JSON. +fn render_json_inspection(inspection: &StructureInspection) -> CommandReport { + let mut value = summary_value(inspection); + if inspection.verbose { + value["metadata"] = metadata_value(&inspection.parsed.structure); + value["diagnostics_detail"] = json!( + inspection + .parsed + .diagnostics + .iter() + .map(|diagnostic| json!({ + "code": diagnostic.code, + "line": diagnostic.line, + "severity": format!("{:?}", diagnostic.severity), + "message": diagnostic.message, + })) + .collect::>() + ); + } + json_report(CommandReportStatus::Succeeded, value) +} + +/// Renders a structure validation result and preserves its failure status. +/// +/// # Parameters +/// +/// * `validation` contains the requested format and optional invariant failure. +/// +/// # Returns +/// +/// A report whose status matches the validation result. +fn render_validation(validation: &StructureValidation) -> CommandReport { + match validation.output { + CommandOutputFormat::Text => { + let (status, tone, text) = match validation.error.as_deref() { + Some(error) => ( + CommandReportStatus::Failed, + CommandOutputTone::Error, + format!("✗ Invalid structure: {error}"), + ), + None => ( + CommandReportStatus::Succeeded, + CommandOutputTone::Success, + format!( + "✓ Valid structure: {} ({})", + validation.path.display(), + validation.format.label() + ), + ), + }; + CommandReport { + status, + kind: CommandOutputKind::Text, + lines: vec![semantic_line(tone, text)], + } + } + CommandOutputFormat::Json => json_report( + if validation.is_valid() { + CommandReportStatus::Succeeded + } else { + CommandReportStatus::Failed + }, + json!({ + "path": validation.path.display().to_string(), + "format": validation.format.id(), + "valid": validation.is_valid(), + "error": validation.error, + }), + ), + } +} + +/// Builds one label-value row with independent semantic tones. +fn paired_line(label: impl Into, value: impl Into, value_tone: CommandOutputTone) -> CommandOutputLine { + CommandOutputLine::new([ + CommandOutputSpan::new(label, CommandOutputTone::Secondary), + CommandOutputSpan::new(value, value_tone), + ]) +} + +/// Builds one row with a uniform semantic tone. +fn semantic_line(tone: CommandOutputTone, text: impl Into) -> CommandOutputLine { + CommandOutputLine::new([CommandOutputSpan::new(text, tone)]) +} + +/// Appends every physical row from a formatted multi-line value. +fn extend_multiline(lines: &mut Vec, text: &str, tone: CommandOutputTone) { + lines.extend(text.lines().map(|line| semantic_line(tone, line))); +} + +/// Returns stable count labels shared by text and JSON reports. +fn summary_counts(structure: &Structure) -> [(&'static str, usize); 10] { + [ + ("Models", structure.models().len()), + ("Chains", structure.chains().len()), + ("Residues", structure.residues().len()), + ("Atoms", structure.atoms().len()), + ("Bonds", structure.bonds().len()), + ("Polymer entities", structure.polymer_entities().len()), + ("Missing polymer residues", structure.missing_polymer_residues().len()), + ("Secondary ranges", structure.secondary_ranges().len()), + ("Assembly operations", structure.metadata().assembly.operations.len()), + ("Biological assemblies", structure.metadata().assembly.assemblies.len()), + ] +} + +/// Builds the stable JSON inspection summary. +/// +/// # Parameters +/// +/// * `inspection` contains source metadata, diagnostics, and parsed structure data. +/// +/// # Returns +/// +/// A JSON object containing stable scalar metadata and structure counts. +fn summary_value(inspection: &StructureInspection) -> Value { + let mut value = json!({ + "path": inspection.path.display().to_string(), + "format": inspection.format.id(), + "bytes": inspection.byte_count, + "diagnostics": inspection.parsed.diagnostics.len(), + }); + for (name, count) in summary_counts(&inspection.parsed.structure) { + value[name.to_ascii_lowercase().replace(' ', "_")] = json!(count); + } + value +} + +/// Builds stable JSON metadata for verbose inspection output. +/// +/// # Parameters +/// +/// * `structure` supplies crystallographic and biological assembly metadata. +/// +/// # Returns +/// +/// A JSON object suitable for the verbose inspection report. +fn metadata_value(structure: &Structure) -> Value { + json!({ + "classification": structure.metadata().classification, + "identifier": structure.metadata().identifier, + "unit_cell": structure.metadata().unit_cell.as_ref().map(|cell| json!({ + "lengths": cell.lengths, + "angles": cell.angles, + })), + "symmetry": structure.metadata().symmetry.as_ref().map(|symmetry| json!({ + "space_group_name": symmetry.space_group_name, + "international_tables_number": symmetry.international_tables_number, + })), + "assembly": { + "operations": structure.metadata().assembly.operations.iter().map(|operation| json!({ + "id": operation.id, + "rotation": operation.rotation, + "translation": operation.translation, + })).collect::>(), + "assemblies": structure.metadata().assembly.assemblies.iter().map(|assembly| json!({ + "id": assembly.id, + "details": assembly.details, + "generations": assembly.generations.iter().map(|generation| json!({ + "asym_ids": generation.asym_ids, + "auth_asym_ids": generation.auth_asym_ids, + "entity_instance_ids": generation.entity_instance_ids, + "operator_expression": generation.operator_expression, + })).collect::>(), + })).collect::>(), + }, + }) +} + +/// Converts a JSON value into canonical pretty-printed physical rows. +/// +/// # Parameters +/// +/// * `status` is the domain-level completion state. +/// * `value` is the complete machine-readable result. +/// +/// # Returns +/// +/// A JSON report split into physical rows for consistent frontend rendering. +fn json_report(status: CommandReportStatus, value: Value) -> CommandReport { + let text = format!("{value:#}"); + CommandReport { + status, + kind: CommandOutputKind::Json, + lines: text.lines().map(CommandOutputLine::plain).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn report_plain_text_should_preserve_semantic_span_order() { + let report = CommandReport { + status: CommandReportStatus::Succeeded, + kind: CommandOutputKind::Text, + lines: vec![paired_line("Format: ", "mmCIF", CommandOutputTone::Success)], + }; + + assert_eq!(report.plain_text(), "Format: mmCIF"); + } + + #[test] + fn database_report_should_use_the_shared_success_message() { + let report = render_outcome(&CommandOutcome::DatabaseDownload { artifact_count: 2 }); + + assert_eq!(report.plain_text(), "Downloaded 2 structure artifact(s)."); + } + + #[test] + fn invalid_structure_report_should_carry_failure_without_a_second_error() { + let outcome = CommandOutcome::StructureValidation(StructureValidation { + path: "invalid.pdb".into(), + format: chitin_databases::providers::rcsb::StructureFormat::Pdb, + output: CommandOutputFormat::Text, + error: Some("no atom records were found".to_owned()), + }); + + let report = render_outcome(&outcome); + + assert_eq!( + (report.status, report.plain_text()), + ( + CommandReportStatus::Failed, + "✗ Invalid structure: no atom records were found".to_owned() + ) + ); + } +} diff --git a/crates/chitin-command/src/panel_tab.rs b/crates/chitin-command/src/panel_tab.rs deleted file mode 100644 index bde6a133..00000000 --- a/crates/chitin-command/src/panel_tab.rs +++ /dev/null @@ -1,56 +0,0 @@ -/// Commands for document-panel tabs. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PanelTabCommand { - /// Focus the previous tab. - FocusPrevious, - /// Focus the next tab. - FocusNext, - /// Close the active tab. - Close, -} - -impl PanelTabCommand { - /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { - match self { - Self::FocusPrevious => "tab.focus_previous", - Self::FocusNext => "tab.focus_next", - Self::Close => "tab.close", - } - } -} - -/// Returns the document-panel tab command descriptors. -pub fn command_descriptors() -> Vec { - [ - ( - PanelTabCommand::FocusPrevious, - "Focus Previous Tab", - &["document", "panel", "tab", "previous"][..], - Some("Shift+J"), - ), - ( - PanelTabCommand::FocusNext, - "Focus Next Tab", - &["document", "panel", "tab", "next"][..], - Some("Shift+K"), - ), - ( - PanelTabCommand::Close, - "Close Active Tab", - &["document", "panel", "tab", "close"][..], - Some("Shift+X"), - ), - ] - .into_iter() - .map(|(command, title, keywords, shortcut)| crate::CommandDescriptor { - id: command.id(), - title, - category: crate::CommandCategory::Workspace, - keywords, - shortcut, - invocation: crate::CommandInvocationKind::Immediate, - command: crate::ChitinCommand::from(crate::WorkspaceCommand::PanelTab(command)), - }) - .collect() -} diff --git a/crates/chitin-command/src/structure.rs b/crates/chitin-command/src/structure.rs deleted file mode 100644 index 4525ba01..00000000 --- a/crates/chitin-command/src/structure.rs +++ /dev/null @@ -1,18 +0,0 @@ -/// Structure parsing and inspection commands. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum StructureCommand { - /// Print a parsed structure summary. - Inspect, - /// Parse a structure and verify its cross-table invariants. - Validate, -} - -impl StructureCommand { - /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { - match self { - Self::Inspect => "structure.inspect", - Self::Validate => "structure.validate", - } - } -} diff --git a/crates/chitin-command/src/workspace.rs b/crates/chitin-command/src/workspace.rs deleted file mode 100644 index be5f52dd..00000000 --- a/crates/chitin-command/src/workspace.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::PanelTabCommand; - -/// Workspace and project-tree commands. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum WorkspaceCommand { - /// Focus the previous project entry. - FocusPrevious, - /// Focus the next project entry. - FocusNext, - /// Activate the focused project entry. - ActivateFocused, - /// Focus the first project entry. - FocusFirst, - /// Focus the last project entry. - FocusLast, - /// Show or hide the workspace sidebar. - ToggleWorkspace, - /// Execute a document-panel tab command. - PanelTab(PanelTabCommand), -} - -impl WorkspaceCommand { - /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { - match self { - Self::FocusPrevious => "workspace.focus_previous_entry", - Self::FocusNext => "workspace.focus_next_entry", - Self::ActivateFocused => "workspace.activate_focused_entry", - Self::FocusFirst => "workspace.focus_first_entry", - Self::FocusLast => "workspace.focus_last_entry", - Self::ToggleWorkspace => "workspace.toggle_workspace", - Self::PanelTab(command) => command.id(), - } - } -} - -/// Returns the project-workspace command descriptors. -pub fn command_descriptors() -> Vec { - [ - ( - WorkspaceCommand::ToggleWorkspace, - "Toggle Workspace Sidebar", - &["files", "project", "sidebar", "explorer"][..], - Some("Shift+E"), - ), - ( - WorkspaceCommand::FocusPrevious, - "Focus Previous Project Entry", - &["tree", "up", "previous"][..], - Some("Up"), - ), - ( - WorkspaceCommand::FocusNext, - "Focus Next Project Entry", - &["tree", "down", "next"][..], - Some("Down"), - ), - ( - WorkspaceCommand::ActivateFocused, - "Activate Focused Project Entry", - &["open", "tree", "file", "directory"][..], - Some("Enter"), - ), - ( - WorkspaceCommand::FocusFirst, - "Focus First Project Entry", - &["tree", "home", "first"][..], - Some("Home"), - ), - ( - WorkspaceCommand::FocusLast, - "Focus Last Project Entry", - &["tree", "end", "last"][..], - Some("End"), - ), - ] - .into_iter() - .map(|(command, title, keywords, shortcut)| crate::CommandDescriptor { - id: command.id(), - title, - category: crate::CommandCategory::Workspace, - keywords, - shortcut, - invocation: crate::CommandInvocationKind::Immediate, - command: command.into(), - }) - .collect() -} diff --git a/crates/chitin-desktop/Cargo.toml b/crates/chitin-desktop/Cargo.toml index 1545471c..ece1fa67 100644 --- a/crates/chitin-desktop/Cargo.toml +++ b/crates/chitin-desktop/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] +chitin-builtin-shell = { path = "../chitin-builtin-shell" } chitin-command = { path = "../chitin-command" } chitin-bio = { path = "../chitin-bio" } log = "0.4.33" @@ -13,13 +14,14 @@ env_logger = "0.10" futures-util = "0.3" chitin-databases = { path = "../chitin-databases" } chitin-molecule-renderer = { path = "../chitin-molecule-renderer" } +chitin-terminal = { path = "../chitin-terminal" } chitin-utils = { path = "../chitin-utils" } chitin-ui = { path = "../chitin-ui" } chitin-wgpu = { path = "../chitin-wgpu" } gpui = { package = "gpui-ce", git = "https://github.com/chitin-dev/gpui-wgpu", rev = "d01b063d0dce9df2be24a53f59201e010712fc01" } tokio = { version = "1.52", features = ["rt-multi-thread", "sync"] } wgpu = { version = "30", default-features = false, features = ["vulkan", "wgsl"] } -rust-embed = { version = "8.12.0", features = ["debug-embed", "interpolate-folder-path"] } +rust-embed = { version = "8.12.0", features = ["debug-embed", "include-exclude", "interpolate-folder-path"] } thiserror = "2.0" [dev-dependencies] diff --git a/crates/chitin-desktop/examples/chitin-wgpu-desktop.rs b/crates/chitin-desktop/examples/chitin-wgpu-desktop.rs index bdf34c88..61a07939 100644 --- a/crates/chitin-desktop/examples/chitin-wgpu-desktop.rs +++ b/crates/chitin-desktop/examples/chitin-wgpu-desktop.rs @@ -22,6 +22,7 @@ use std::{ use chitin_bio::structure::{MmcifParser, PdbParser, StructureScene}; use chitin_desktop::{ app::{ChitinApp, WgpuDocumentView, WgpuDocumentViewFactory}, + fonts::register_terminal_fonts, keybindings::default_key_bindings, wgpu_panel::ChitinWgpuDocumentPanel, }; @@ -194,6 +195,11 @@ fn main() { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"), }) .run(move |cx: &mut App| { + if let Err(error) = register_terminal_fonts(cx) { + eprintln!("failed to register bundled terminal fonts: {error}"); + cx.quit(); + return; + } cx.bind_keys(default_key_bindings()); let bounds = Bounds::centered(None, size(px(1180.0), px(800.0)), cx); diff --git a/crates/chitin-desktop/examples/terminal-debug.rs b/crates/chitin-desktop/examples/terminal-debug.rs new file mode 100644 index 00000000..67396c58 --- /dev/null +++ b/crates/chitin-desktop/examples/terminal-debug.rs @@ -0,0 +1,648 @@ +#![forbid(unsafe_code)] +//! Native PTY and VT-emulation integration example. +//! +//! Run with `cargo run -p chitin-desktop --example terminal-debug -- [WORKING_DIRECTORY]`. + +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use chitin_desktop::fonts::{TERMINAL_FONT_FAMILY, register_terminal_fonts}; +use chitin_terminal::{ + TerminalCell, TerminalCellAttributes, TerminalColor, TerminalEvent, TerminalNamedColor, TerminalSession, + TerminalSize, TerminalSnapshot, +}; +use gpui::{ + App, AppContext, Application, AsyncApp, Bounds, Context, FocusHandle, Font, FontFallbacks, FontFeatures, FontWeight, + Hsla, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels, Render, SharedString, + Styled, Task, TextRun, Timer, WeakEntity, Window, WindowBounds, WindowOptions, div, font, prelude::*, px, rgb, size, +}; + +const FONT_SIZE: f32 = 15.0; +const LINE_HEIGHT_MULTIPLIER: f32 = 1.4; +const VIEWPORT_PADDING: f32 = 24.0; +const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(16); + +/// Font-derived geometry shared by layout, painting, and the native PTY. +#[derive(Clone, Copy)] +struct TerminalMetrics { + cell_width: Pixels, + cell_height: Pixels, + font_size: Pixels, +} + +/// Standalone GPUI host for a native terminal session. +struct TerminalDebugView { + session: TerminalSession, + focus: FocusHandle, + size: TerminalSize, + title: String, + status: Option, + _event_task: Task<()>, +} + +impl TerminalDebugView { + /// Spawns a native shell and starts repainting when the VT grid changes. + /// + /// # Parameters + /// + /// * `working_directory` is the initial directory for the system shell. + /// * `size` is the measured initial PTY grid, before the shell emits its prompt. + /// * `cx` creates the focus handle and terminal-event polling task. + /// + /// # Returns + /// + /// A debug view backed by a real pseudo-terminal. + fn new(working_directory: PathBuf, size: TerminalSize, cx: &mut Context) -> Result { + let session = TerminalSession::spawn_default_shell(&working_directory, size).map_err(|error| error.to_string())?; + let event_task = cx.spawn(|this: WeakEntity, async_cx: &mut AsyncApp| { + let mut async_cx = async_cx.clone(); + async move { + loop { + Timer::after(EVENT_POLL_INTERVAL).await; + let should_continue = this + .update(&mut async_cx, |view, cx| { + view.process_events(cx); + true + }) + .unwrap_or(false); + if !should_continue { + break; + } + } + } + }); + + Ok(Self { + session, + focus: cx.focus_handle(), + size, + title: "Chitin native terminal".into(), + status: None, + _event_task: event_task, + }) + } + + /// Applies pending session events and schedules a repaint when necessary. + fn process_events(&mut self, cx: &mut Context) { + let events = self.session.drain_events(); + if events.is_empty() { + return; + } + + for event in events { + match event { + TerminalEvent::Title(title) => self.title = title, + TerminalEvent::Exited(code) => self.status = Some(format!("process exited with code {code}")), + TerminalEvent::Error(error) => self.status = Some(error), + TerminalEvent::Render | TerminalEvent::Bell => {} + } + } + cx.notify(); + } + + /// Resizes the native PTY when the GPUI window's character capacity changes. + fn resize_for_window(&mut self, window: &Window, metrics: TerminalMetrics) { + let next = terminal_size_for_window(window, metrics); + if next == self.size { + return; + } + + if let Err(error) = self.session.resize(next) { + self.status = Some(error.to_string()); + } else { + self.size = next; + } + } + + /// Encodes one GPUI key event and writes it directly to the PTY. + fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + let Some(bytes) = encode_key(event) else { + return; + }; + if let Err(error) = self.session.write(&bytes) { + self.status = Some(error.to_string()); + cx.notify(); + } + cx.stop_propagation(); + } +} + +impl Render for TerminalDebugView { + /// Renders the current VT character grid and a small diagnostic status line. + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let metrics = measure_terminal_metrics(window); + self.resize_for_window(window, metrics); + let snapshot = self.session.snapshot(); + let rows = (0..snapshot.rows).map(|row| { + div().flex().h(metrics.cell_height).children( + build_row_runs(&snapshot, row) + .into_iter() + .map(move |run| render_run(run, metrics)), + ) + }); + let focus = self.focus.clone(); + + div() + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .track_focus(&self.focus) + .on_mouse_down(MouseButton::Left, move |_, window, cx| window.focus(&focus, cx)) + .on_key_down(cx.listener(|this, event, _, cx| this.handle_key(event, cx))) + .bg(rgb(0x101216)) + .font(terminal_font()) + .text_size(metrics.font_size) + .line_height(metrics.cell_height) + .child( + div() + .flex() + .items_center() + .h(metrics.cell_height + px(6.0)) + .px_3() + .text_color(rgb(0x8b949e)) + .child(self.status.clone().unwrap_or_else(|| self.title.clone())), + ) + .child(div().flex().flex_col().px_3().children(rows)) + } +} + +/// Measures the bundled terminal font and derives one fixed character grid. +fn measure_terminal_metrics(window: &mut Window) -> TerminalMetrics { + let font_size = px(FONT_SIZE); + let regular_width = measure_terminal_cell_width(window, font_size, FontWeight::NORMAL); + let bold_width = measure_terminal_cell_width(window, font_size, FontWeight::BOLD); + + TerminalMetrics { + cell_width: regular_width.max(bold_width).max(px(1.0)), + cell_height: px((FONT_SIZE * LINE_HEIGHT_MULTIPLIER).round()).max(px(1.0)), + font_size, + } +} + +/// Measures one upright terminal cell at the requested font weight. +fn measure_terminal_cell_width(window: &mut Window, font_size: Pixels, weight: FontWeight) -> Pixels { + let mut terminal_font = terminal_font(); + terminal_font.weight = weight; + window + .text_system() + .shape_line( + SharedString::new_static("M"), + font_size, + &[TextRun { + len: 1, + font: terminal_font, + color: Hsla::default().into(), + ..TextRun::default() + }], + None, + ) + .width +} + +/// Derives the PTY grid from the current drawable window and font metrics. +fn terminal_size_for_window(window: &Window, metrics: TerminalMetrics) -> TerminalSize { + let bounds = window.bounds(); + let cell_width = f32::from(metrics.cell_width); + let cell_height = f32::from(metrics.cell_height); + let width = (f32::from(bounds.size.width) - VIEWPORT_PADDING).max(cell_width); + let height = (f32::from(bounds.size.height) - VIEWPORT_PADDING - cell_height).max(cell_height); + let columns = (width / cell_width).floor().max(1.0) as usize; + let rows = (height / cell_height).floor().max(1.0) as usize; + + TerminalSize::new(columns, rows, cell_width.ceil() as u16, cell_height.ceil() as u16) +} + +/// Disables contextual and standard ligatures so one glyph cannot span several cells. +fn terminal_font_features() -> FontFeatures { + FontFeatures(Arc::new(vec![ + ("calt".into(), 0), + ("liga".into(), 0), + ("clig".into(), 0), + ])) +} + +/// Builds the upright terminal font stack, including symbol and emoji fallbacks. +fn terminal_font() -> Font { + let mut terminal_font = font(TERMINAL_FONT_FAMILY); + terminal_font.features = terminal_font_features(); + terminal_font.fallbacks = Some(FontFallbacks::from_fonts(vec![ + "Symbols Nerd Font".into(), + "Noto Color Emoji".into(), + "Segoe UI Emoji".into(), + "Apple Color Emoji".into(), + "Noto Sans Symbols 2".into(), + ])); + terminal_font +} + +/// A consecutive sequence of cells that share one paint style. +struct TerminalRun { + text: String, + columns: usize, + foreground: TerminalColor, + background: TerminalColor, + attributes: TerminalCellAttributes, + /// Whether the run holds a single glyph that is painted across two grid columns. + wide: bool, + cursor: bool, +} + +impl TerminalRun { + /// Starts a run with the visual state of one terminal cell. + fn new(cell: &TerminalCell, cursor: bool) -> Self { + let mut run = Self { + text: String::new(), + columns: 0, + foreground: cell.foreground, + background: cell.background, + attributes: run_attributes(cell.attributes), + wide: cell.attributes.wide, + cursor, + }; + run.push(cell); + run + } + + /// Returns whether another cell can share this run's shaping and paint operation. + fn matches(&self, cell: &TerminalCell, cursor: bool) -> bool { + self.foreground == cell.foreground + && self.background == cell.background + && self.attributes == run_attributes(cell.attributes) + && self.wide == cell.attributes.wide + && self.cursor == cursor + } + + /// Appends one cell while retaining its fixed grid-column budget. + fn push(&mut self, cell: &TerminalCell) { + // A wide cell is the only cell of its run, and it is the one that accounts for both of + // the columns the glyph covers. + self.columns += if cell.attributes.wide { 2 } else { 1 }; + if cell.attributes.hidden { + self.text.push(' '); + return; + } + + self.text.push(cell.character); + self.text.extend(cell.combining_characters.iter()); + } +} + +/// Groups one row into the smallest set of consecutive equal-style text runs. +fn build_row_runs(snapshot: &TerminalSnapshot, row: usize) -> Vec { + let mut runs: Vec = Vec::new(); + let visible_columns = (0..snapshot.columns) + .rfind(|&column| { + snapshot.cursor == Some((row, column)) || snapshot.cell(row, column).is_some_and(|cell| !is_default_blank(cell)) + }) + .map_or(0, |column| column + 1); + + for column in 0..visible_columns { + let Some(cell) = snapshot.cell(row, column) else { + continue; + }; + // The cell that continues a wide glyph is a placeholder: the wide glyph's own run + // already covers its column, and painting it as a space would cover the right half of + // the glyph that precedes it. + if cell.attributes.wide_spacer { + continue; + } + let cursor = snapshot.cursor == Some((row, column)); + // A wide glyph is placed by the grid rather than by the text flow, because its advance + // is not exactly two cell widths. Keeping it in a run of its own leaves every following + // run starting exactly where the grid says the next column starts. + let extended = runs.last_mut().filter(|run| !run.wide && run.matches(cell, cursor)); + match extended { + Some(run) => run.push(cell), + None => runs.push(TerminalRun::new(cell, cursor)), + } + } + runs +} + +/// Returns whether a cell is already represented by the viewport background. +fn is_default_blank(cell: &TerminalCell) -> bool { + cell.character == ' ' + && cell.combining_characters.is_empty() + && cell.foreground == TerminalColor::Named(TerminalNamedColor::Foreground) + && cell.background == TerminalColor::Named(TerminalNamedColor::Background) + && upright_attributes(cell.attributes) == TerminalCellAttributes::default() +} + +/// Removes font slant while preserving the remaining terminal text attributes. +fn upright_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAttributes { + attributes.italic = false; + attributes +} + +/// Attributes that decide where one painted run ends and the next one begins. +/// +/// Slant is dropped because the grid font ships upright only, and the wide-continuation +/// marker is dropped because it describes the cell's role in the grid rather than how the +/// cell looks. +fn run_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAttributes { + attributes.italic = false; + attributes.wide_spacer = false; + attributes +} + +/// Renders one equal-style text run using a fixed terminal-column width. +fn render_run(run: TerminalRun, metrics: TerminalMetrics) -> impl IntoElement { + let (mut foreground, mut background) = (resolve_color(run.foreground), resolve_color(run.background)); + if run.attributes.inverse { + std::mem::swap(&mut foreground, &mut background); + } + if run.attributes.dim { + foreground = foreground.opacity(0.62); + } + if run.cursor { + std::mem::swap(&mut foreground, &mut background); + if background == resolve_color(TerminalColor::Named(TerminalNamedColor::Background)) { + background = rgb(0xd0d7de).into(); + } + } + + div() + .flex_none() + .w(metrics.cell_width * run.columns as f32) + .h(metrics.cell_height) + .whitespace_nowrap() + .bg(background) + .text_color(foreground) + .when(run.attributes.bold, |element| element.font_weight(FontWeight::BOLD)) + .when(run.attributes.underline, |element| element.underline()) + .child(run.text) +} + +/// Resolves a VT color against the debug terminal's xterm-compatible palette. +fn resolve_color(color: TerminalColor) -> Hsla { + match color { + TerminalColor::Rgb { red, green, blue } => { + rgb((u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue)).into() + } + TerminalColor::Indexed(index) => indexed_color(index), + TerminalColor::Named(named) => named_color(named), + } +} + +/// Resolves semantic and ANSI color names used by the VT state machine. +fn named_color(color: TerminalNamedColor) -> Hsla { + let value = match color { + TerminalNamedColor::Black | TerminalNamedColor::DimBlack => 0x1f2329, + TerminalNamedColor::Red | TerminalNamedColor::DimRed => 0xff5f56, + TerminalNamedColor::Green | TerminalNamedColor::DimGreen => 0x63d17c, + TerminalNamedColor::Yellow | TerminalNamedColor::DimYellow => 0xe5c07b, + TerminalNamedColor::Blue | TerminalNamedColor::DimBlue => 0x61afef, + TerminalNamedColor::Magenta | TerminalNamedColor::DimMagenta => 0xc678dd, + TerminalNamedColor::Cyan | TerminalNamedColor::DimCyan => 0x56b6c2, + TerminalNamedColor::White | TerminalNamedColor::DimWhite => 0xabb2bf, + TerminalNamedColor::BrightBlack => 0x5c6370, + TerminalNamedColor::BrightRed => 0xff7b72, + TerminalNamedColor::BrightGreen => 0x7ee787, + TerminalNamedColor::BrightYellow => 0xf2cc60, + TerminalNamedColor::BrightBlue => 0x79c0ff, + TerminalNamedColor::BrightMagenta => 0xd2a8ff, + TerminalNamedColor::BrightCyan => 0x76e3ea, + TerminalNamedColor::BrightWhite | TerminalNamedColor::BrightForeground => 0xffffff, + TerminalNamedColor::Foreground => 0xd0d7de, + TerminalNamedColor::Background => 0x101216, + TerminalNamedColor::Cursor => 0xd0d7de, + TerminalNamedColor::DimForeground => 0x8b949e, + }; + rgb(value).into() +} + +/// Converts one xterm-256 palette index into an RGB color. +fn indexed_color(index: u8) -> Hsla { + const ANSI: [u32; 16] = [ + 0x1f2329, 0xff5f56, 0x63d17c, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xabb2bf, 0x5c6370, 0xff7b72, 0x7ee787, + 0xf2cc60, 0x79c0ff, 0xd2a8ff, 0x76e3ea, 0xffffff, + ]; + let value = match index { + 0..=15 => ANSI[index as usize], + 16..=231 => { + let cube = index - 16; + let component = |value: u8| if value == 0 { 0 } else { 55 + value * 40 }; + let red = component(cube / 36); + let green = component((cube % 36) / 6); + let blue = component(cube % 6); + (u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue) + } + 232..=255 => { + let gray = 8 + (index - 232) * 10; + (u32::from(gray) << 16) | (u32::from(gray) << 8) | u32::from(gray) + } + }; + rgb(value).into() +} + +/// Converts a GPUI keystroke into the byte sequence expected by a terminal application. +fn encode_key(event: &KeyDownEvent) -> Option> { + let key = event.keystroke.key.as_str(); + let modifiers = event.keystroke.modifiers; + let special = match key { + "enter" => Some(b"\r".as_slice()), + "backspace" => Some(b"\x7f".as_slice()), + "tab" if modifiers.shift => Some(b"\x1b[Z".as_slice()), + "tab" => Some(b"\t".as_slice()), + "escape" => Some(b"\x1b".as_slice()), + "up" => Some(b"\x1b[A".as_slice()), + "down" => Some(b"\x1b[B".as_slice()), + "right" => Some(b"\x1b[C".as_slice()), + "left" => Some(b"\x1b[D".as_slice()), + "home" => Some(b"\x1b[H".as_slice()), + "end" => Some(b"\x1b[F".as_slice()), + "pageup" => Some(b"\x1b[5~".as_slice()), + "pagedown" => Some(b"\x1b[6~".as_slice()), + "delete" => Some(b"\x1b[3~".as_slice()), + "insert" => Some(b"\x1b[2~".as_slice()), + _ => None, + }; + if let Some(special) = special { + return Some(special.to_vec()); + } + if modifiers.platform { + return None; + } + + let text = event.keystroke.key_char.as_deref()?; + let mut bytes = if modifiers.control && text.len() == 1 { + let byte = text.as_bytes()[0].to_ascii_uppercase(); + if byte.is_ascii_uppercase() { + vec![byte & 0x1f] + } else { + return None; + } + } else { + text.as_bytes().to_vec() + }; + if modifiers.alt { + bytes.insert(0, 0x1b); + } + Some(bytes) +} + +/// Opens the native terminal integration example. +fn main() { + let working_directory = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + + Application::new().run(move |cx: &mut App| { + if let Err(error) = register_terminal_fonts(cx) { + eprintln!("failed to register bundled terminal fonts: {error}"); + cx.quit(); + return; + } + let result = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(960.0), px(640.0)), + cx, + ))), + app_id: Some("dev.chitin.TerminalDebug".into()), + ..Default::default() + }, + move |window, cx| { + window.activate_window(); + let metrics = measure_terminal_metrics(window); + let terminal_size = terminal_size_for_window(window, metrics); + cx.new( + |cx| match TerminalDebugView::new(working_directory, terminal_size, cx) { + Ok(view) => view, + Err(error) => panic!("failed to start native terminal example: {error}"), + }, + ) + }, + ); + + if let Err(error) = result { + eprintln!("failed to open native terminal example: {error}"); + cx.quit(); + return; + } + cx.activate(true); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn row_builder_should_merge_equal_style_cells() { + let cell = TerminalCell { + character: 'x', + ..TerminalCell::default() + }; + let snapshot = TerminalSnapshot { + columns: 120, + rows: 1, + cells: vec![cell; 120], + cursor: None, + display_offset: 0, + history_size: 0, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].columns, 120); + } + + #[test] + fn row_builder_should_skip_trailing_default_background_cells() { + let mut cells = vec![TerminalCell::default(); 120]; + cells[0].character = 'x'; + let snapshot = TerminalSnapshot { + columns: 120, + rows: 1, + cells, + cursor: None, + display_offset: 0, + history_size: 0, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].columns, 1); + } + + #[test] + fn cursor_should_split_only_its_own_cell_from_a_run() { + let cell = TerminalCell { + character: 'x', + ..TerminalCell::default() + }; + let snapshot = TerminalSnapshot { + columns: 120, + rows: 1, + cells: vec![cell; 120], + cursor: Some((0, 60)), + display_offset: 0, + history_size: 0, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 3); + assert_eq!(runs.iter().map(|run| run.columns).collect::>(), [60, 1, 59]); + } + + #[test] + fn row_builder_should_render_italic_cells_upright() { + let cell = TerminalCell { + character: 'x', + attributes: TerminalCellAttributes { + italic: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + }; + let snapshot = TerminalSnapshot { + columns: 1, + rows: 1, + cells: vec![cell], + cursor: None, + display_offset: 0, + history_size: 0, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 1); + assert!(!runs[0].attributes.italic); + } + + #[test] + fn row_builder_should_merge_upright_and_italic_cells() { + let upright = TerminalCell { + character: 'a', + ..TerminalCell::default() + }; + let italic = TerminalCell { + character: 'b', + attributes: TerminalCellAttributes { + italic: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + }; + let snapshot = TerminalSnapshot { + columns: 2, + rows: 1, + cells: vec![upright, italic], + cursor: None, + display_offset: 0, + history_size: 0, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text, "ab"); + } +} diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index 8f0a4630..99c1149d 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -5,9 +5,12 @@ use std::{collections::BTreeSet, path::PathBuf}; +use chitin_command::CommandExecutor; +use chitin_databases::ClientConfig; use chitin_ui::{ composite::{ activity_bar::DEFAULT_ACTIVITY_BAR_WIDTH, + bottom_dock::BottomDockState, panel::{PanelSplitAxis, PanelTabDrag}, toast::{Toast, ToastHost, ToastId, ToastViewport}, window_bar::DEFAULT_WINDOW_BAR_HEIGHT, @@ -20,14 +23,18 @@ use gpui::{ }; use crate::{ + builtin_shell::{DesktopShellHost, desktop_shell_context}, components::{ activity_bar::{ActiveActivity, ActivityBarControls, render_activity_bar}, + bottom_dock::BottomDockControls, command_panel::{CommandPanelController, render_command_panel}, document_area::{DocumentOptionsControls, DocumentPanelState, render_document_area, state::DocumentPanelContent}, project_sidebar::{ProjectSidebarState, render_project_sidebar}, + terminal::{TERMINAL_DOCK_ITEM_ID, TerminalPanelControls, TerminalPanelState, render_terminal_bottom_dock}, window_bar::{WindowBarControls, render_window_bar}, }, - keybindings::{ToggleCommandPanel, ToggleWorkspace}, + keybindings::{ToggleCommandPanel, ToggleTerminal, ToggleWorkspace, WORKBENCH_KEY_CONTEXT}, + portable_command::DesktopPortableCommandRunner, tasks::BackgroundTaskCenter, }; @@ -55,6 +62,18 @@ pub struct ChitinApp { pub(crate) command_panel: CommandPanelController, /// Application-wide executor and registry for background work. pub(crate) tasks: BackgroundTaskCenter, + /// Shared adapter for frontend-independent commands submitted by desktop views. + pub(crate) portable_commands: DesktopPortableCommandRunner, + /// Application-level command bridge for agent and system invocations. + pub(crate) builtin_shell: DesktopShellHost, + /// Active tool and geometry of the workbench-level bottom dock. + pub(crate) bottom_dock: BottomDockState, + /// Persistent controls shared by all bottom-dock tools. + pub(crate) bottom_dock_controls: Option, + /// Scrollback presentation, shell mapping, and focus state for the terminal. + pub(crate) terminal_panel: TerminalPanelState, + /// Persistent primitive controls for the bottom terminal. + pub(crate) terminal_panel_controls: Option, /// Structure paths currently being read and parsed off the UI thread. pub(crate) pending_structure_loads: BTreeSet, /// Primitive button state for platform window actions. @@ -124,6 +143,8 @@ impl ChitinApp { let project_sidebar_state = ProjectSidebarState::with_workspace_root(workspace.as_ref().map(|workspace| workspace.tree.root.path.as_path())); + let shell_workspace_root = workspace.as_ref().map(|workspace| workspace.root.clone()); + let command_executor = CommandExecutor::new(ClientConfig::default()); Self { workspace, project_sidebar_state, @@ -135,6 +156,12 @@ impl ChitinApp { project_sidebar_visible: true, command_panel: CommandPanelController::new(), tasks: BackgroundTaskCenter::new(), + portable_commands: DesktopPortableCommandRunner::new(command_executor), + builtin_shell: DesktopShellHost::new(desktop_shell_context(shell_workspace_root)), + bottom_dock: BottomDockState::new(), + bottom_dock_controls: None, + terminal_panel: TerminalPanelState::new(), + terminal_panel_controls: None, pending_structure_loads: BTreeSet::new(), window_bar_controls: None, activity_bar_controls: None, @@ -372,7 +399,7 @@ impl ChitinApp { /// /// The approximate height available to the document panel root after removing /// the window bar. - fn document_panel_root_height(window_height: gpui::Pixels) -> gpui::Pixels { + pub(crate) fn document_panel_root_height(window_height: gpui::Pixels) -> gpui::Pixels { gpui::px((f32::from(window_height) - f32::from(DEFAULT_WINDOW_BAR_HEIGHT)).max(0.0)) } @@ -456,12 +483,30 @@ impl Render for ChitinApp { let toast_viewport = self.toast_viewport(cx); let command_panel_search_input = self.command_panel_search_input(window, cx); self.command_panel_rcsb_form(window, cx); + let terminal_dock_controls = if self.bottom_dock.is_active(TERMINAL_DOCK_ITEM_ID) { + self + .terminal_panel_controls(window, cx) + .map(|controls| (controls, self.bottom_dock_controls(window, cx))) + } else { + None + }; + if terminal_dock_controls.is_some() { + self.sync_terminal_output(); + } + if self.terminal_panel.take_focus_request() + && let Some((controls, _)) = terminal_dock_controls.as_ref() + && let Some(focus) = controls.focus(cx) + { + window.focus(&focus, cx); + } let app = cx.weak_entity(); let workbench_focus = self.workbench_focus(cx); let project_sidebar_focus = self.project_sidebar_focus(cx); let document_panel_focus = self.document_panel_focus(cx); let project_sidebar_is_resizing = self.project_sidebar_state.is_resizing(); let document_panel_resize_axis = self.document_panel_resize_axis(); + let bottom_dock_is_resizing = self.bottom_dock.is_resizing(); + let bottom_dock_height = self.bottom_dock.height(); let visible_sidebar_width = if self.active_activity == ActiveActivity::Workspace && self.project_sidebar_visible { self.project_sidebar_state.resize.width() } else { @@ -473,6 +518,7 @@ impl Render for ChitinApp { .flex_col() .size_full() .relative() + .key_context(WORKBENCH_KEY_CONTEXT) .track_focus(&workbench_focus) .bg(theme.background.primary) .text_color(theme.text.primary) @@ -484,6 +530,9 @@ impl Render for ChitinApp { cx.notify(); } })) + .on_action(cx.listener(|this, _: &ToggleTerminal, _, cx| { + this.toggle_terminal(cx); + })) .capture_key_down({ let app = app.clone(); move |event, window, cx| { @@ -502,10 +551,13 @@ impl Render for ChitinApp { .when(document_panel_resize_axis == Some(PanelSplitAxis::Vertical), |layout| { layout.cursor(CursorStyle::ResizeUpDown) }) + .when(bottom_dock_is_resizing, |layout| { + layout.cursor(CursorStyle::ResizeUpDown) + }) .on_mouse_move({ let app = app.clone(); move |event, window, cx| { - if !project_sidebar_is_resizing && document_panel_resize_axis.is_none() { + if !project_sidebar_is_resizing && document_panel_resize_axis.is_none() && !bottom_dock_is_resizing { return; } @@ -532,6 +584,15 @@ impl Render for ChitinApp { } }); } + + if bottom_dock_is_resizing { + let available_height = Self::document_panel_root_height(window.bounds().size.height); + let _ = app.update(cx, |this, cx| { + if this.bottom_dock.drag_resize(event.position.y, available_height) { + cx.notify(); + } + }); + } } }) .on_drag_move::({ @@ -550,9 +611,10 @@ impl Render for ChitinApp { let _ = app.update(cx, |this, cx| { let sidebar_stopped = this.project_sidebar_state.stop_resize(); let document_panel_stopped = this.stop_document_panel_resize(); + let bottom_dock_stopped = this.bottom_dock.stop_resize(); let document_tab_drag_cancelled = this.cancel_document_panel_tab_drag(); - if sidebar_stopped || document_panel_stopped || document_tab_drag_cancelled { + if sidebar_stopped || document_panel_stopped || bottom_dock_stopped || document_tab_drag_cancelled { cx.notify(); } }); @@ -577,14 +639,32 @@ impl Render for ChitinApp { )) }, ) - .child(render_document_area( - &self.document_panels, - theme, - &document_panel_focus, - app.clone(), - document_options_controls, - cx, - )), + .child( + div() + .relative() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .min_h_0() + .child(render_document_area( + &self.document_panels, + theme, + &document_panel_focus, + app.clone(), + document_options_controls, + cx, + )) + .when_some(terminal_dock_controls, |layout, (controls, dock_controls)| { + layout.child(render_terminal_bottom_dock( + controls, + dock_controls.close(), + bottom_dock_height, + theme, + app.clone(), + )) + }), + ), ) .when(self.command_panel.is_open(), |layout| { layout.child(render_command_panel( diff --git a/crates/chitin-desktop/src/builtin_shell.rs b/crates/chitin-desktop/src/builtin_shell.rs new file mode 100644 index 00000000..c4b7de09 --- /dev/null +++ b/crates/chitin-desktop/src/builtin_shell.rs @@ -0,0 +1,400 @@ +//! Desktop host adapter for the frontend-independent built-in shell. + +use std::path::PathBuf; + +use chitin_builtin_shell::{ + BuiltinShell, BuiltinShellError, ShellBuiltinEffect, ShellCommandId, ShellCommandTarget, ShellExecutionResult, + ShellInvocationSource, ShellLineSubmission, ShellSubmission, +}; +use chitin_command::{ChitinCommand, CommandEventSink, CommandExecutionContext}; +use gpui::{AppContext, AsyncApp, Context, WeakEntity, Window}; + +use crate::{ + app::ChitinApp, + portable_command::{ + DesktopPortableCommandError, DesktopPortableCommandRunner, DesktopPortableCommandTask, PortableCommandCompletion, + PortableCommandSubmission, + }, + tasks::{BackgroundTaskCenter, TaskHandle, TaskKind}, +}; + +/// Desktop-owned shell session and adapters for application background work. +#[derive(Clone)] +pub struct DesktopShellHost { + session: BuiltinShell, +} + +impl DesktopShellHost { + /// Creates a desktop shell from workspace-aware execution defaults. + pub fn new(context: CommandExecutionContext) -> Self { + Self { + session: BuiltinShell::from_context(context), + } + } + + /// Returns the session owned by this desktop shell host. + pub fn session(&self) -> &BuiltinShell { + &self.session + } + + /// Parses and reserves an attributed command-line submission. + pub fn submit_line( + &self, + input: impl Into, + source: ShellInvocationSource, + ) -> Result { + Ok(self.session.submit_line(input, source)?) + } + + /// Reserves an already typed command for an agent or internal subsystem. + /// + /// # Parameters + /// + /// * `input` is a human-readable audit representation of the request. + /// * `command` is the validated command sent across the shell/kernel bridge. + /// * `source` attributes the invocation to its agent or subsystem. + /// + /// # Returns + /// + /// A correlated submission ready for desktop routing. + pub fn submit_typed( + &self, + input: impl Into, + command: ChitinCommand, + source: ShellInvocationSource, + ) -> Result { + Ok(self.session.submit_typed(input, command, source)?) + } + + /// Runs a portable shell submission through the application task center. + /// + /// # Parameters + /// + /// * `submission` contains the correlated typed command and execution context. + /// * `tasks` owns the shared Tokio executor and durable task state. + /// * `runner` resolves targets and dispatches portable work through the + /// application-wide command executor. + /// + /// # Returns + /// + /// A task handle for UI observation and a structured result receiver for + /// terminal or agent callers. + pub fn submit_portable( + &self, + submission: ShellSubmission, + tasks: &BackgroundTaskCenter, + runner: &DesktopPortableCommandRunner, + ) -> Result { + if submission.target() != ShellCommandTarget::Portable { + return Err(DesktopShellHostError::WrongTarget { + id: submission.id(), + target: submission.target(), + }); + } + + let id = submission.id(); + let command_id = submission.command().id(); + let ChitinCommand::Portable(command) = submission.command().clone() else { + return Err(DesktopShellHostError::WrongTarget { + id, + target: submission.target(), + }); + }; + let cancellation = submission.context().cancellation.clone(); + let context = submission.context().clone(); + let events = self + .session + .portable_event_sink(&submission, CommandEventSink::silent())?; + let completion_session = self.session.clone(); + let failure_session = self.session.clone(); + let task = runner.submit( + tasks, + PortableCommandSubmission::new(TaskKind::BuiltinShell { command_id }, command, context) + .with_cancellation(cancellation) + .with_events(events) + .on_completion(move |completion| { + let result = match completion { + PortableCommandCompletion::Succeeded => completion_session.complete_portable(id), + PortableCommandCompletion::Cancelled => completion_session.cancel_portable(id), + PortableCommandCompletion::Failed(message) => completion_session.fail_submission(id, message), + }; + if let Err(error) = result { + log::error!("failed to update built-in shell completion state: {error}"); + } + }), + ); + match task { + Ok(task) => Ok(DesktopShellTask { command_id: id, task }), + Err(error) => { + let _ = failure_session.fail_submission(id, error.to_string()); + Err(error.into()) + } + } + } + + /// Completes a command after the desktop frontend applies its UI mutation. + pub fn complete_frontend(&self, id: ShellCommandId) -> Result<(), DesktopShellHostError> { + Ok(self.session.complete_frontend(id)?) + } + + /// Records a desktop routing failure for a frontend command. + pub fn fail_frontend(&self, id: ShellCommandId, message: impl Into) -> Result<(), DesktopShellHostError> { + Ok(self.session.fail_frontend(id, message)?) + } +} + +/// Running portable shell command exposed to desktop and agent consumers. +pub struct DesktopShellTask { + command_id: ShellCommandId, + task: DesktopPortableCommandTask, +} + +impl DesktopShellTask { + /// Returns the shell-local command identity. + pub const fn command_id(&self) -> ShellCommandId { + self.command_id + } + + /// Returns the background task observation handle. + pub const fn task(&self) -> &TaskHandle { + self.task.task() + } + + /// Waits for the structured portable-command result. + pub async fn result(self) -> Result { + let outcome = self.task.result().await?; + Ok(ShellExecutionResult { + id: self.command_id, + outcome, + }) + } +} + +/// Result of routing one shell submission through the desktop host. +pub enum DesktopShellDispatch { + /// Clap produced help text without scheduling a command. + Display { output: String }, + /// The shell session synchronously produced a presentation effect. + ShellBuiltin { effect: ShellBuiltinEffect }, + /// The command mutated desktop state synchronously. + Frontend { command_id: ShellCommandId }, + /// The command is running through the shared background executor. + Portable(DesktopShellTask), +} + +/// Failure while preparing or executing a desktop-hosted shell command. +#[derive(Debug, thiserror::Error)] +pub enum DesktopShellHostError { + /// The frontend-independent shell rejected the request or execution. + #[error(transparent)] + Shell(#[from] BuiltinShellError), + /// Portable command preparation, submission, or execution failed. + #[error(transparent)] + Portable(#[from] DesktopPortableCommandError), + /// A submission was sent to an incompatible host route. + #[error("shell command {} has target {target:?}, which cannot use this route", id.get())] + WrongTarget { + /// Shell-local identity of the incorrectly routed command. + id: ShellCommandId, + /// Target selected by the frontend-independent shell. + target: ShellCommandTarget, + }, +} + +/// Builds workspace-aware defaults for a desktop shell session. +pub(crate) fn desktop_shell_context(workspace_root: Option) -> CommandExecutionContext { + match workspace_root { + Some(root) => CommandExecutionContext::new(&root) + .with_workspace_root(&root) + .with_default_download_root(root.join(".chitin").join("download")), + None => CommandExecutionContext::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))), + } +} + +impl ChitinApp { + /// Returns the application-level built-in shell used by agent and system callers. + pub fn builtin_shell(&self) -> &BuiltinShell { + self.builtin_shell.session() + } + + /// Parses and routes one textual built-in shell command through the desktop host. + /// + /// # Parameters + /// + /// * `input` is one complete command line. + /// * `source` identifies the interactive, agent, or system caller. + /// * `window` provides the context required by frontend UI commands. + /// * `cx` schedules portable task observation on the GPUI main thread. + /// + /// # Returns + /// + /// A completed frontend dispatch or a handle to the running portable command. + pub fn submit_builtin_shell_line( + &mut self, + input: impl Into, + source: ShellInvocationSource, + window: &mut Window, + cx: &mut Context, + ) -> Result { + let host = self.builtin_shell.clone(); + self.submit_shell_line_with_host(&host, input, source, window, cx) + } + + /// Routes text through a specific shell session, such as one terminal tab. + pub(crate) fn submit_shell_line_with_host( + &mut self, + host: &DesktopShellHost, + input: impl Into, + source: ShellInvocationSource, + window: &mut Window, + cx: &mut Context, + ) -> Result { + match host.submit_line(input, source)? { + ShellLineSubmission::Command(submission) => self.route_builtin_shell_submission(host, submission, window, cx), + ShellLineSubmission::ShellBuiltin(effect) => Ok(DesktopShellDispatch::ShellBuiltin { effect }), + ShellLineSubmission::Display(output) => Ok(DesktopShellDispatch::Display { output }), + } + } + + /// Routes an already typed command from an agent or application subsystem. + /// + /// # Parameters + /// + /// * `input` is a human-readable audit representation of the request. + /// * `command` contains the validated command identity and arguments. + /// * `source` identifies the agent or subsystem initiating the request. + /// * `window` provides the context required by frontend UI commands. + /// * `cx` schedules portable task observation on the GPUI main thread. + /// + /// # Returns + /// + /// A completed frontend dispatch or a handle to the running portable command. + pub fn submit_builtin_shell_command( + &mut self, + input: impl Into, + command: ChitinCommand, + source: ShellInvocationSource, + window: &mut Window, + cx: &mut Context, + ) -> Result { + let host = self.builtin_shell.clone(); + let submission = host.submit_typed(input, command, source)?; + self.route_builtin_shell_submission(&host, submission, window, cx) + } + + /// Routes one prepared submission without reparsing its typed command. + fn route_builtin_shell_submission( + &mut self, + host: &DesktopShellHost, + submission: ShellSubmission, + window: &mut Window, + cx: &mut Context, + ) -> Result { + match submission.target() { + ShellCommandTarget::Frontend => { + let command_id = submission.id(); + let ChitinCommand::Frontend(command) = submission.command() else { + return Err(DesktopShellHostError::WrongTarget { + id: command_id, + target: submission.target(), + }); + }; + self.dispatch_command_with_window(command.clone(), window, cx); + host.complete_frontend(command_id)?; + Ok(DesktopShellDispatch::Frontend { command_id }) + } + ShellCommandTarget::Portable => { + let running = host.submit_portable(submission, &self.tasks, &self.portable_commands)?; + observe_shell_task(running.task().clone(), window, cx); + Ok(DesktopShellDispatch::Portable(running)) + } + } + } +} + +/// Notifies GPUI while a portable shell command changes task state. +fn observe_shell_task(mut task: TaskHandle, window: &Window, cx: &mut Context) { + let window_handle = window.window_handle(); + cx.spawn(async move |app: WeakEntity, async_cx: &mut AsyncApp| { + while let Some(snapshot) = task.changed().await { + let terminal = snapshot.state.is_terminal(); + let _ = async_cx.update_window(window_handle, |_, _, cx| { + let _ = app.update(cx, |_, cx| cx.notify()); + }); + if terminal { + break; + } + } + }) + .detach(); +} + +#[cfg(test)] +mod tests { + use chitin_command::{CommandExecutor, CommandOutputFormat}; + use chitin_databases::{ClientConfig, providers::rcsb::StructureFormat}; + + use super::*; + + #[test] + fn portable_submission_should_return_a_correlated_kernel_result() -> Result<(), Box> { + let host = DesktopShellHost::new(CommandExecutionContext::new(".").with_standard_input( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), + )); + let submission = host + .submit_line( + "structure validate - --format pdb", + ShellInvocationSource::Agent { + name: "validation-agent".to_owned(), + }, + )? + .into_command()?; + let command_id = submission.id(); + let tasks = BackgroundTaskCenter::new(); + let runner = DesktopPortableCommandRunner::new(CommandExecutor::new(ClientConfig::default())); + let running = host.submit_portable(submission, &tasks, &runner)?; + let result_runtime = tokio::runtime::Builder::new_current_thread().build()?; + + let result = result_runtime.block_on(running.result())?; + + assert_eq!(result.id, command_id); + assert!(matches!( + result.outcome, + chitin_command::CommandOutcome::StructureValidation(validation) + if validation.format == StructureFormat::Pdb + && validation.output == CommandOutputFormat::Text + && validation.is_valid() + )); + Ok(()) + } + + #[test] + fn frontend_submission_should_remain_available_for_desktop_dispatch() -> Result<(), DesktopShellHostError> { + let host = DesktopShellHost::new(CommandExecutionContext::new(".")); + + let submission = host + .submit_line("tab.close", ShellInvocationSource::Interactive)? + .into_command()?; + + assert_eq!(submission.target(), ShellCommandTarget::Frontend); + host.complete_frontend(submission.id())?; + assert!(host.session().snapshot()?.active.is_none()); + Ok(()) + } + + #[test] + fn separate_terminal_hosts_should_keep_commands_and_history_independent() -> Result<(), DesktopShellHostError> { + let first = DesktopShellHost::new(CommandExecutionContext::new(".")); + let second = DesktopShellHost::new(CommandExecutionContext::new(".")); + + let submission = first + .submit_line("tab.close", ShellInvocationSource::Interactive)? + .into_command()?; + first.complete_frontend(submission.id())?; + + assert_eq!(first.session().snapshot()?.executions.len(), 1); + assert!(second.session().snapshot()?.executions.is_empty()); + assert_eq!(second.session().previous_history("")?, None); + Ok(()) + } +} diff --git a/crates/chitin-desktop/src/components/bottom_dock.rs b/crates/chitin-desktop/src/components/bottom_dock.rs new file mode 100644 index 00000000..5a647b10 --- /dev/null +++ b/crates/chitin-desktop/src/components/bottom_dock.rs @@ -0,0 +1,62 @@ +//! Desktop-owned controls for the reusable workbench bottom dock. + +use chitin_ui::primitive::button::{ButtonEvent, ButtonState}; +use gpui::{AppContext, Context, Entity, Subscription, Window}; + +use crate::app::ChitinApp; + +/// Persistent controls shared by every bottom-dock item. +#[derive(Clone)] +pub(crate) struct BottomDockControls { + close: Entity, +} + +impl BottomDockControls { + /// Creates and subscribes the generic dock controls. + fn new(window: &mut Window, cx: &mut Context) -> Self { + let close = cx.new(ButtonState::new); + let subscription: Subscription = cx.subscribe_in(&close, window, |this, _, event, _, cx| { + if matches!(event, ButtonEvent::Click) { + this.close_bottom_dock(cx); + } + }); + subscription.detach(); + Self { close } + } + + /// Returns the shared dock close-button state. + pub(crate) fn close(&self) -> Entity { + self.close.clone() + } +} + +impl ChitinApp { + /// Returns generic bottom-dock controls, creating them on first use. + pub(crate) fn bottom_dock_controls(&mut self, window: &mut Window, cx: &mut Context) -> BottomDockControls { + if let Some(controls) = self.bottom_dock_controls.as_ref() { + return controls.clone(); + } + let controls = BottomDockControls::new(window, cx); + self.bottom_dock_controls = Some(controls.clone()); + controls + } + + /// Closes whichever tool is active in the bottom dock. + pub(crate) fn close_bottom_dock(&mut self, cx: &mut Context) { + if self.bottom_dock.is_active(super::terminal::TERMINAL_DOCK_ITEM_ID) { + if let Some(controls) = self.terminal_panel_controls.as_ref() { + for session in &controls.sessions { + if let Some(builtin) = session.builtin.as_ref() + && let Err(error) = builtin.host.session().cancel_active() + { + log::warn!("failed to cancel active built-in terminal command: {error}"); + } + } + } + self.terminal_panel_controls = None; + } + self.bottom_dock.close(); + self.terminal_panel.request_focus(false); + cx.notify(); + } +} diff --git a/crates/chitin-desktop/src/components/command_panel.rs b/crates/chitin-desktop/src/components/command_panel.rs index eb8d9e5a..4bad2532 100644 --- a/crates/chitin-desktop/src/components/command_panel.rs +++ b/crates/chitin-desktop/src/components/command_panel.rs @@ -1,16 +1,16 @@ //! Desktop command panel rendering and input handling. -use std::{collections::BTreeSet, rc::Rc}; +use std::rc::Rc; mod controller; mod form; pub(crate) use controller::CommandPanelController; use controller::{CommandPanelEvent, CommandPanelMode}; -use form::rcsb::{RcsbDownloadState, RcsbFormPanel, download_path}; +use form::rcsb::{RcsbDownloadState, RcsbFormPanel}; -use chitin_command::{ApplicationCommand, ChitinCommand, CommandInvocationKind}; -use chitin_databases::providers::rcsb::{PdbId, RcsbBatchDownloadRequest}; +use chitin_command::{CommandExecutionContext, CommandId, DatabaseCommand, PortableCommand, RcsbDownloadArguments}; +use chitin_databases::providers::rcsb::PdbId; use chitin_ui::{ composite::{ quickpick::{QuickPickItem, QuickPickOverlay, QuickPickSearchInput, render_quick_pick_overlay}, @@ -33,7 +33,8 @@ use gpui::{ use crate::{ app::ChitinApp, components::document_area::state::OpenedProjectDocument, - tasks::{TaskOutput, TaskState, rcsb::submit_downloads}, + portable_command::PortableCommandSubmission, + tasks::{TaskKind, TaskOutput, TaskState}, }; /// Callback invoked when a rendered command row is selected. @@ -57,7 +58,11 @@ pub(crate) fn render_command_panel( cx: &mut Context, search_input: Entity, ) -> Div { - let rcsb_form = matches!(controller.mode(), CommandPanelMode::Form(_)).then(|| controller.rcsb_form(cx)); + let rcsb_form = matches!( + controller.mode(), + CommandPanelMode::Form(CommandId::DatabaseDownloadRcsbStructure) + ) + .then(|| controller.rcsb_form(cx)); let focus_handle = rcsb_form .as_ref() .map(|form| form.pdb_id.read(cx).focus_handle().clone()) @@ -84,28 +89,25 @@ pub(crate) fn render_command_panel( } let results = controller.registry().search(controller.query()); - let selected_commands = results - .iter() - .map(|result| result.descriptor.command.clone()) - .collect::>(); + let selected_ids = results.iter().map(|result| result.spec.id).collect::>(); let overlay = QuickPickOverlay { query: controller.query().into(), placeholder: "Type a command".into(), search_input: Some(QuickPickSearchInput::new(search_input)), items: results .iter() - .map(|result| QuickPickItem::new(result.descriptor.title, result.descriptor.shortcut)) + .map(|result| QuickPickItem::new(result.spec.title, result.spec.shortcut)) .collect(), selected_index: controller.selected_index(), scroll_handle: Some(controller.result_scroll_handle()), empty_message: "No commands found".into(), }; let on_select: Rc = Rc::new(move |index, window, cx: &mut App| { - let Some(command) = selected_commands.get(index).cloned() else { + let Some(id) = selected_ids.get(index).copied() else { return; }; let _ = app.update(cx, |app, cx| { - app.invoke_command_from_panel(command, window, cx); + app.invoke_command_from_panel(id, window, cx); }); }); @@ -150,7 +152,10 @@ impl ChitinApp { window: &mut Window, cx: &mut Context, ) -> Option { - if !matches!(self.command_panel.mode(), CommandPanelMode::Form(_)) { + if !matches!( + self.command_panel.mode(), + CommandPanelMode::Form(CommandId::DatabaseDownloadRcsbStructure) + ) { return None; } let form = self.command_panel.rcsb_form(cx); @@ -189,7 +194,7 @@ impl ChitinApp { Some(form) } - /// Submits the current RCSB form and closes its modal panel. + /// Validates the current RCSB form and dispatches a typed download command. fn submit_rcsb_form(&mut self, window: &mut Window, cx: &mut Context) { let Some(form) = self.command_panel.rcsb_form_if_created() else { return; @@ -198,10 +203,6 @@ impl ChitinApp { log::debug!("RCSB download submission ignored because a batch is already active"); return; } - let Some(workspace_root) = self.workspace.as_ref().map(|workspace| workspace.root.clone()) else { - log::warn!("RCSB download ignored because no workspace is open"); - return; - }; let raw_pdb_ids = form.pdb_id.read(cx).text().trim().to_owned(); let ids = match PdbId::parse_many(&raw_pdb_ids) { Ok(ids) => ids, @@ -215,25 +216,66 @@ impl ChitinApp { } }; let format = form.selected_format(cx); - let mut unique_ids = BTreeSet::new(); - let requests = ids - .into_iter() - .filter(|pdb_id| unique_ids.insert(pdb_id.clone())) - .map(|pdb_id| RcsbBatchDownloadRequest { - destination: download_path(&workspace_root, &pdb_id, format), - id: pdb_id, + self.execute_rcsb_download( + RcsbDownloadArguments { + ids, format, - }) - .collect::>(); - let total_items = requests.len(); - let mut task = match submit_downloads(&self.tasks, requests) { - Ok(task) => task, + output: None, + }, + window, + cx, + ); + } + + /// Executes a typed RCSB download against the active desktop workspace. + /// + /// # Parameters + /// + /// * `arguments` contains validated identifiers, format, and output override. + /// * `window` identifies the window that receives completed documents. + /// * `cx` submits the background task and updates form presentation state. + /// + /// # Returns + /// + /// This function returns after the background task has been submitted. + pub(crate) fn execute_rcsb_download( + &mut self, + arguments: RcsbDownloadArguments, + window: &mut Window, + cx: &mut Context, + ) { + let Some(form) = self.command_panel.rcsb_form_if_created() else { + return; + }; + let Some(workspace_root) = self.workspace.as_ref().map(|workspace| workspace.root.clone()) else { + let error = "RCSB download requires an open workspace".to_string(); + log::warn!("{error}"); + form.download.update(cx, |state, cx| state.fail(error, cx)); + return; + }; + let execution_context = CommandExecutionContext::new(&workspace_root) + .with_workspace_root(&workspace_root) + .with_default_download_root(workspace_root.join(".chitin").join("download")); + let command = PortableCommand::from(DatabaseCommand::DownloadRcsbStructure(arguments)); + let running = match self.portable_commands.submit( + &self.tasks, + PortableCommandSubmission::new( + TaskKind::DatabaseDownload { + provider: "RCSB".to_string(), + }, + command, + execution_context, + ), + ) { + Ok(submission) => submission, Err(error) => { log::error!("failed to submit RCSB download task: {error}"); form.download.update(cx, |state, cx| state.fail(error.to_string(), cx)); return; } }; + let total_items = running.target_count(); + let mut task = running.into_task_handle(); log::info!( "RCSB batch download task submitted: id={}, items={total_items}", task.id() @@ -317,45 +359,36 @@ impl ChitinApp { self.command_panel.close(window, cx); cx.notify(); } - CommandPanelEvent::Invoke(command) => self.invoke_command_from_panel(command, window, cx), + CommandPanelEvent::Invoke(id) => self.invoke_command_from_panel(id, window, cx), } } /// Invokes one command selected from the command panel. /// # Parameters /// - /// * `command` is the typed command selected by the controller. + /// * `id` is the command identity selected by the controller. /// * `window` receives focus restoration when an immediate command closes the panel. /// * `cx` is used to dispatch or notify state changes. - pub(crate) fn invoke_command_from_panel( - &mut self, - command: ChitinCommand, - window: &mut Window, - cx: &mut Context, - ) { - let Some(invocation) = self - .command_panel - .registry() - .descriptor_for(&command) - .map(|descriptor| descriptor.invocation) - else { + pub(crate) fn invoke_command_from_panel(&mut self, id: CommandId, window: &mut Window, cx: &mut Context) { + let Some(spec) = self.command_panel.registry().spec_for(id) else { return; }; - match invocation { - CommandInvocationKind::Form => { - if self.command_panel.open_form(command) { - cx.notify(); - } - } - CommandInvocationKind::Immediate => { - self.command_panel.close(window, cx); - if command == ChitinCommand::Application(ApplicationCommand::ToggleCommandPanel) { - cx.notify(); - } else { - self.dispatch_command_with_window(command, window, cx); - } + if spec.requires_arguments { + if self.command_panel.open_form(id) { + cx.notify(); } + return; + } + + let Some(command) = id.frontend_command_without_arguments() else { + return; + }; + self.command_panel.close(window, cx); + if id == CommandId::ApplicationToggleCommandPanel { + cx.notify(); + } else { + self.dispatch_command_with_window(command, window, cx); } } } diff --git a/crates/chitin-desktop/src/components/command_panel/controller.rs b/crates/chitin-desktop/src/components/command_panel/controller.rs index 66404356..35f7e3a0 100644 --- a/crates/chitin-desktop/src/components/command_panel/controller.rs +++ b/crates/chitin-desktop/src/components/command_panel/controller.rs @@ -1,6 +1,6 @@ //! Desktop-owned command-panel state and focus management. -use chitin_command::{ChitinCommand, CommandRegistry}; +use chitin_command::{CommandId, CommandRegistry}; use chitin_ui::{composite::quickpick::QuickPickState, primitive::input::text::TextInputState}; use gpui::{AppContext, Context, Entity, FocusHandle, KeyDownEvent, ScrollStrategy, UniformListScrollHandle, Window}; @@ -12,7 +12,7 @@ pub(crate) enum CommandPanelMode { /// Search mode listing matching commands. Search, /// Form mode for one command, identified without duplicating its descriptor. - Form(ChitinCommand), + Form(CommandId), } /// Result of handling one command-panel key event. @@ -23,7 +23,7 @@ pub(crate) enum CommandPanelEvent { /// The panel should close and restore its prior focus target. Close, /// A command should be invoked from the current search result. - Invoke(ChitinCommand), + Invoke(CommandId), } /// State and focus owner for the desktop command panel. @@ -215,17 +215,20 @@ impl CommandPanelController { /// /// # Parameters /// - /// * `command` identifies the command that owns the form metadata. + /// * `id` identifies the command that owns the form metadata. /// /// # Returns /// /// `true` when the command is registered and the panel entered form mode. - pub(crate) fn open_form(&mut self, command: ChitinCommand) -> bool { - if self.registry.descriptor_for(&command).is_none() { + pub(crate) fn open_form(&mut self, id: CommandId) -> bool { + let Some(spec) = self.registry.spec_for(id) else { + return false; + }; + if !spec.requires_arguments { return false; } - self.mode = CommandPanelMode::Form(command); + self.mode = CommandPanelMode::Form(id); self.rcsb_focus_pending = true; self.quickpick.reset(); true @@ -319,7 +322,7 @@ impl CommandPanelController { .registry .search(self.quickpick.query()) .get(self.quickpick.selected_index()) - .map(|result| CommandPanelEvent::Invoke(result.descriptor.command.clone())) + .map(|result| CommandPanelEvent::Invoke(result.spec.id)) .unwrap_or(CommandPanelEvent::StateChanged) } } @@ -364,10 +367,10 @@ mod tests { #[test] fn open_form_should_store_only_command_identity() { let mut controller = CommandPanelController::new(); - let command = ChitinCommand::from(chitin_command::DatabaseCommand::DownloadRcsbStructure); + let id = CommandId::DatabaseDownloadRcsbStructure; - assert!(controller.open_form(command.clone())); - assert_eq!(controller.mode, CommandPanelMode::Form(command)); + assert!(controller.open_form(id)); + assert_eq!(controller.mode, CommandPanelMode::Form(id)); } #[test] diff --git a/crates/chitin-desktop/src/components/command_panel/form/rcsb.rs b/crates/chitin-desktop/src/components/command_panel/form/rcsb.rs index 7a731547..eccff69b 100644 --- a/crates/chitin-desktop/src/components/command_panel/form/rcsb.rs +++ b/crates/chitin-desktop/src/components/command_panel/form/rcsb.rs @@ -1,8 +1,6 @@ //! RCSB structure-download form rendered inside the command panel. -use std::path::{Path, PathBuf}; - -use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; +use chitin_databases::providers::rcsb::StructureFormat; use chitin_ui::{ primitive::{ @@ -24,18 +22,6 @@ use gpui::{ use crate::tasks::{TaskSnapshot, TaskState}; -/// Builds the final workspace download path for an RCSB structure. -pub(crate) fn download_path(workspace_root: &Path, pdb_id: &PdbId, format: StructureFormat) -> PathBuf { - workspace_root - .join(".chitin") - .join("download") - .join(match format { - StructureFormat::Pdb => "pdb", - StructureFormat::Mmcif => "mmcif", - }) - .join(format.filename(pdb_id)) -} - /// Persistent primitive state for the RCSB download form. #[derive(Clone)] pub(crate) struct RcsbFormPanel { @@ -398,31 +384,7 @@ impl RcsbFormPanel { #[cfg(test)] mod tests { - use std::path::Path; - - use chitin_databases::providers::rcsb::PdbId; - - use super::{RcsbDownloadState, StructureFormat, download_path, download_progress_label}; - - #[test] - fn pdb_download_path_should_use_pdb_directory_and_extension() -> Result<(), Box> { - let id = PdbId::new("1yth")?; - - let path = download_path(Path::new("/workspace"), &id, StructureFormat::Pdb); - - assert_eq!(path, Path::new("/workspace/.chitin/download/pdb/1YTH.pdb")); - Ok(()) - } - - #[test] - fn mmcif_download_path_should_use_mmcif_directory_and_cif_extension() -> Result<(), Box> { - let id = PdbId::new("1yth")?; - - let path = download_path(Path::new("/workspace"), &id, StructureFormat::Mmcif); - - assert_eq!(path, Path::new("/workspace/.chitin/download/mmcif/1YTH.cif")); - Ok(()) - } + use super::{RcsbDownloadState, download_progress_label}; #[test] fn cancelled_download_should_render_cancelled_label() { diff --git a/crates/chitin-desktop/src/components/mod.rs b/crates/chitin-desktop/src/components/mod.rs index f6d23fc5..a891b0bb 100644 --- a/crates/chitin-desktop/src/components/mod.rs +++ b/crates/chitin-desktop/src/components/mod.rs @@ -1,9 +1,11 @@ //! Desktop-specific views and application wiring components. pub mod activity_bar; +pub mod bottom_dock; pub mod command_panel; pub mod document_area; pub mod project_sidebar; +pub mod terminal; pub mod wgpu_panel; pub mod window_bar; pub mod workspace_tree; diff --git a/crates/chitin-desktop/src/components/terminal/completion.rs b/crates/chitin-desktop/src/components/terminal/completion.rs new file mode 100644 index 00000000..4ef2bbdb --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/completion.rs @@ -0,0 +1,59 @@ +//! Completion-candidate handling for the desktop terminal prompt. + +/// Action the live prompt takes for one set of completion candidates. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum CompletionAction { + /// Replace the line with the only match, followed by a word separator. + Insert(String), + /// List candidates without changing the editable line. + List(Vec), + /// Leave the line untouched. + None, +} + +/// Decides what one completion result does to the live prompt. +/// +/// # Parameters +/// +/// * `candidates` are the full replacement lines reported by shell completion. +/// +/// # Returns +/// +/// A single candidate is inserted so repeated Tab presses converge on one +/// command. Several candidates are listed instead, because the grammar offers +/// no unambiguous choice and overwriting the line would discard user input. +pub(super) fn completion_action(candidates: Vec) -> CompletionAction { + match candidates.as_slice() { + [] => CompletionAction::None, + [line] => CompletionAction::Insert(format!("{line} ")), + _ => CompletionAction::List(candidates), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_completion_should_leave_the_line_untouched() { + assert_eq!(completion_action(Vec::new()), CompletionAction::None); + } + + #[test] + fn a_unique_completion_should_be_inserted_with_a_separator() { + assert_eq!( + completion_action(vec!["clear".to_owned()]), + CompletionAction::Insert("clear ".to_owned()) + ); + } + + #[test] + fn ambiguous_completion_should_list_candidates_without_editing() { + let candidates = vec!["tab.close".to_owned(), "tab.focus_next".to_owned()]; + + assert_eq!( + completion_action(candidates.clone()), + CompletionAction::List(candidates) + ); + } +} diff --git a/crates/chitin-desktop/src/components/terminal/controller.rs b/crates/chitin-desktop/src/components/terminal/controller.rs new file mode 100644 index 00000000..b2c7871c --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/controller.rs @@ -0,0 +1,296 @@ +//! Built-in shell routing for the VT terminal backend. + +use chitin_builtin_shell::{BuiltinShell, BuiltinTerminalEvent, ShellBuiltinEffect, ShellInvocationSource}; +use gpui::{AppContext, AsyncApp, Context, WeakEntity, Window}; + +use super::{ + TerminalSessionId, + completion::completion_action, + presenter::{shell_output_lines, terminal_lines_ansi}, +}; +use crate::{app::ChitinApp, builtin_shell::DesktopShellDispatch, components::terminal::completion::CompletionAction}; + +impl ChitinApp { + /// Drains byte-stream events produced by the in-process shell backend. + pub(super) fn process_builtin_terminal_input( + &mut self, + session_id: TerminalSessionId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| builtin.program.clone()) + else { + return; + }; + let events = match program.lock() { + Ok(mut program) => match program.poll() { + Ok(events) => events, + Err(error) => { + log::error!("failed to poll built-in terminal input: {error}"); + return; + } + }, + Err(_) => { + log::error!("built-in terminal program lock is poisoned"); + return; + } + }; + + for event in events { + self.handle_builtin_terminal_event(session_id, event, window, cx); + } + } + + /// Routes one semantic line-discipline event to shell or terminal behavior. + fn handle_builtin_terminal_event( + &mut self, + session_id: TerminalSessionId, + event: BuiltinTerminalEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(host) = self.terminal_shell_host(session_id) else { + return; + }; + match event { + BuiltinTerminalEvent::Submit(line) => self.submit_terminal_line(session_id, line, window, cx), + BuiltinTerminalEvent::Interrupt => { + if host + .session() + .snapshot() + .is_ok_and(|snapshot| snapshot.active.is_some()) + { + let _ = host.session().cancel_active(); + } + } + BuiltinTerminalEvent::Complete(line) => self.complete_terminal_line(session_id, line), + BuiltinTerminalEvent::PreviousHistory => { + let draft = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) + .and_then(|program| program.lock().ok().map(|program| program.line().to_owned())) + .unwrap_or_default(); + if let Ok(Some(line)) = host.session().previous_history(&draft) { + self.replace_terminal_line(session_id, line); + } + } + BuiltinTerminalEvent::NextHistory => { + if let Ok(Some(line)) = host.session().next_history() { + self.replace_terminal_line(session_id, line); + } + } + BuiltinTerminalEvent::Resize(_) | BuiltinTerminalEvent::Shutdown => {} + } + cx.notify(); + } + + /// Submits one line through the existing typed built-in-shell dispatcher. + fn submit_terminal_line( + &mut self, + session_id: TerminalSessionId, + line: String, + window: &mut Window, + cx: &mut Context, + ) { + let Some(host) = self.terminal_shell_host(session_id) else { + return; + }; + let line = line.trim().to_owned(); + if line.is_empty() { + self.finish_terminal_command(session_id, ""); + return; + } + match self.submit_shell_line_with_host(&host, line, ShellInvocationSource::Interactive, window, cx) { + Ok(DesktopShellDispatch::Display { output }) => self.finish_terminal_command(session_id, &output), + Ok(DesktopShellDispatch::ShellBuiltin { + effect: ShellBuiltinEffect::ClearScrollback, + }) => self.clear_terminal_screen(session_id), + Ok(DesktopShellDispatch::Frontend { .. }) => self.finish_terminal_command(session_id, ""), + Ok(DesktopShellDispatch::Portable(task)) => { + let command_id = task.command_id(); + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.start_command(command_id); + } + let window_handle = window.window_handle(); + cx.spawn(async move |app: WeakEntity, async_cx: &mut AsyncApp| { + let result = task.result().await; + let _ = async_cx.update_window(window_handle, |_, window, cx| { + let _ = app.update(cx, |this, cx| { + let output = match result { + Ok(result) => { + let mut lines = host + .session() + .snapshot() + .map(|snapshot| shell_output_lines(&snapshot, command_id)) + .unwrap_or_default(); + let (_, result_lines) = super::presenter::terminal_outcome(result.outcome); + lines.extend(result_lines); + terminal_lines_ansi(&lines) + } + Err(error) => match host.session().snapshot() { + Ok(snapshot) => { + let output = terminal_lines_ansi(&shell_output_lines(&snapshot, command_id)); + if output.is_empty() { + format!("\x1b[31merror: {error}\x1b[0m") + } else { + output + } + } + Err(_) => format!("\x1b[31merror: {error}\x1b[0m"), + }, + }; + this.finish_terminal_command(session_id, &output); + if let Some(focus) = this.terminal_panel_controls.as_ref().and_then(|controls| { + (controls.active_session == session_id) + .then(|| controls.focus(cx)) + .flatten() + }) { + window.focus(&focus, cx); + } + cx.notify(); + }); + }); + }) + .detach(); + } + Err(error) => self.finish_terminal_command(session_id, &format!("\x1b[31merror: {error}\x1b[0m")), + } + } + + /// Applies built-in shell completion to the terminal-owned editable line. + fn complete_terminal_line(&self, session_id: TerminalSessionId, line: String) { + match completion_action(BuiltinShell::complete(&line)) { + CompletionAction::Insert(completed) => self.replace_terminal_line(session_id, completed), + CompletionAction::List(candidates) => { + let output = candidates.join("\n"); + if let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) + && let Ok(program) = program.lock() + && let Err(error) = program.show_notice(&output) + { + log::error!("failed to display terminal completions: {error}"); + } + } + CompletionAction::None => {} + } + } + + /// Replaces the line edited by the in-process terminal backend. + fn replace_terminal_line(&self, session_id: TerminalSessionId, line: String) { + if let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) + && let Ok(mut program) = program.lock() + && let Err(error) = program.replace_line(line) + { + log::error!("failed to replace built-in terminal line: {error}"); + } + } + + /// Writes final command output and restores the shell prompt. + fn finish_terminal_command(&mut self, session_id: TerminalSessionId, output: &str) { + if let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) + && let Ok(mut program) = program.lock() + && let Err(error) = program.finish_command(output) + { + log::error!("failed to finish built-in terminal command: {error}"); + } + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.finish_command(); + } + } + + /// Clears the VT grid and resets the built-in line discipline. + fn clear_terminal_screen(&mut self, session_id: TerminalSessionId) { + if let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) + && let Ok(mut program) = program.lock() + && let Err(error) = program.clear_screen() + { + log::error!("failed to clear built-in terminal: {error}"); + } + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.finish_command(); + } + } + + /// Projects every running session's replaceable transcript into its own VT grid. + pub(crate) fn sync_terminal_output(&mut self) { + let Some(controls) = self.terminal_panel_controls.as_ref() else { + return; + }; + let running: Vec<_> = controls + .sessions + .iter() + .filter_map(|session| { + Some(( + session.id, + session.active_shell_command?, + session.rendered_running_output.clone(), + session.builtin.clone()?, + )) + }) + .collect(); + + for (session_id, command_id, previous_output, builtin) in running { + let Ok(snapshot) = builtin.host.session().snapshot() else { + continue; + }; + let output = terminal_lines_ansi(&shell_output_lines(&snapshot, command_id)); + if output == previous_output { + continue; + } + let Ok(mut program) = builtin.program.lock() else { + log::error!("built-in terminal program lock is poisoned"); + continue; + }; + if let Err(error) = program.update_command_output(&output) { + log::error!("failed to update built-in terminal output: {error}"); + continue; + } + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.rendered_running_output = output; + } + } + } +} diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs new file mode 100644 index 00000000..d82b2893 --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -0,0 +1,520 @@ +//! Desktop adapter for terminal sessions hosted by the workbench bottom dock. + +mod completion; +mod controller; +mod presenter; +mod render; + +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use chitin_builtin_shell::{BuiltinTerminalProgram, ShellCommandId}; +use chitin_command::CommandExecutionContext; +use chitin_terminal::{ShellCatalog, ShellDefinition, TerminalSession, TerminalSessionError, TerminalSize}; +use chitin_ui::{ + composite::toast::{Toast, ToastVariant}, + primitive::{ + button::{ButtonEvent, ButtonState}, + input::select::{SelectInputEvent, SelectInputState, SelectOption}, + terminal::{TerminalEmulatorEvent, TerminalEmulatorState}, + }, +}; +use gpui::{AppContext, Context, Entity, ScrollHandle, Subscription, Window}; + +use crate::{ + app::ChitinApp, + builtin_shell::{DesktopShellHost, desktop_shell_context}, +}; + +pub(crate) use render::render_terminal_bottom_dock; + +pub(crate) const TERMINAL_DOCK_ITEM_ID: &str = "terminal"; +const INITIAL_TERMINAL_SIZE: TerminalSize = TerminalSize::new(80, 24, 9, 21); + +/// Returns the desktop asset used to represent one terminal profile. +pub(super) fn terminal_profile_icon(id: &str) -> &'static str { + match id { + "builtin-shell" => "icons/terminal-builtin.svg", + "bash" | "git-bash" => "icons/terminal-bash.svg", + "fish" => "icons/terminal-fish.svg", + "powershell" | "pwsh" => "icons/terminal-powershell.svg", + "zsh" => "icons/terminal-zsh.svg", + "nushell" => "icons/terminal-nushell.svg", + _ => "icons/terminal-shell.svg", + } +} + +/// One launchable desktop choice; only PTY shells come from platform discovery. +#[derive(Clone)] +enum DesktopTerminalProfile { + Builtin, + Shell(ShellDefinition), +} + +impl DesktopTerminalProfile { + fn id(&self) -> &str { + match self { + Self::Builtin => "builtin-shell", + Self::Shell(shell) => &shell.id, + } + } + + fn label(&self) -> &str { + match self { + Self::Builtin => "Built-in shell", + Self::Shell(shell) => &shell.label, + } + } +} + +/// Stable identity of one terminal session in the bottom dock. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct TerminalSessionId(u64); + +/// Terminal focus state that belongs to the bottom dock rather than a session. +pub(crate) struct TerminalPanelState { + focus_requested: bool, + next_session_id: u64, +} + +impl TerminalPanelState { + /// Creates a closed terminal panel. + pub(crate) fn new() -> Self { + Self { + focus_requested: false, + next_session_id: 1, + } + } + + /// Updates whether the active session should receive focus on the next render. + pub(crate) fn request_focus(&mut self, requested: bool) { + self.focus_requested = requested; + } + + /// Takes the pending input-focus request. + pub(crate) fn take_focus_request(&mut self) -> bool { + std::mem::take(&mut self.focus_requested) + } + + /// Allocates an identity that is not reused when the dock is closed and reopened. + fn next_session_id(&mut self) -> TerminalSessionId { + let id = TerminalSessionId(self.next_session_id); + self.next_session_id += 1; + id + } +} + +impl Default for TerminalPanelState { + fn default() -> Self { + Self::new() + } +} + +/// One independently rendered terminal surface and its optional in-process program. +#[derive(Clone)] +pub(super) struct ManagedTerminalSession { + pub(super) id: TerminalSessionId, + profile: DesktopTerminalProfile, + pub(super) terminal: Entity, + pub(super) builtin: Option, + pub(super) tab: Entity, + pub(super) active_shell_command: Option, + pub(super) rendered_running_output: String, +} + +/// Command state and byte-stream program owned by one built-in terminal tab. +#[derive(Clone)] +pub(super) struct ManagedBuiltinShell { + pub(super) host: DesktopShellHost, + pub(super) program: Arc>, +} + +impl ManagedTerminalSession { + /// Tracks the portable command currently writing into this session. + fn start_command(&mut self, command_id: ShellCommandId) { + self.active_shell_command = Some(command_id); + self.rendered_running_output.clear(); + } + + /// Clears this session's replaceable command-output projection. + fn finish_command(&mut self) { + self.active_shell_command = None; + self.rendered_running_output.clear(); + } +} + +/// Persistent session manager and controls for the bottom terminal panel. +#[derive(Clone)] +pub(crate) struct TerminalPanelControls { + catalog: ShellCatalog, + pub(super) sessions: Vec, + pub(super) active_session: TerminalSessionId, + pub(super) close_session: Entity, + pub(super) profile_select: Entity, + /// Scroll position of the session tab strip. + /// + /// This has to outlive a render: a handle created while rendering would start every + /// frame at the top of the strip and the lane would never move. + pub(super) tab_scroll: ScrollHandle, +} + +impl TerminalPanelControls { + /// Creates the session manager with an initial built-in shell. + fn new( + initial_id: TerminalSessionId, + working_directory: &Path, + shell_context: CommandExecutionContext, + cx: &mut Context, + ) -> Result { + let catalog = ShellCatalog::discover(); + let close_session = cx.new(ButtonState::new); + let profile_select = cx.new(|cx| { + SelectInputState::new( + std::iter::once( + SelectOption::new("builtin-shell", "Built-in shell").icon(terminal_profile_icon("builtin-shell")), + ) + .chain( + catalog + .available() + .iter() + .map(|shell| SelectOption::new(&shell.id, &shell.label).icon(terminal_profile_icon(&shell.id))), + ), + cx, + ) + }); + let session = Self::build_session( + initial_id, + &DesktopTerminalProfile::Builtin, + working_directory, + shell_context, + cx, + )?; + Ok(Self { + catalog, + sessions: vec![session], + active_session: initial_id, + close_session, + profile_select, + tab_scroll: ScrollHandle::new(), + }) + } + + /// Builds a terminal surface for one launch profile. + fn build_session( + id: TerminalSessionId, + profile: &DesktopTerminalProfile, + working_directory: &Path, + mut shell_context: CommandExecutionContext, + cx: &mut Context, + ) -> Result { + let (session, builtin) = match profile { + DesktopTerminalProfile::Builtin => { + // The prompt and relative-path execution must start in the same directory. + shell_context.working_directory = working_directory.to_path_buf(); + let prompt = presenter::terminal_prompt_ansi(working_directory); + let (session, program) = BuiltinTerminalProgram::connect(INITIAL_TERMINAL_SIZE, prompt)?; + ( + session, + Some(ManagedBuiltinShell { + host: DesktopShellHost::new(shell_context), + program: Arc::new(Mutex::new(program)), + }), + ) + } + DesktopTerminalProfile::Shell(shell) => ( + TerminalSession::spawn_shell(shell, working_directory, INITIAL_TERMINAL_SIZE)?, + None, + ), + }; + Ok(ManagedTerminalSession { + id, + profile: profile.clone(), + terminal: cx.new(|cx| TerminalEmulatorState::new(session, INITIAL_TERMINAL_SIZE, cx)), + builtin, + tab: cx.new(ButtonState::new), + active_shell_command: None, + rendered_running_output: String::new(), + }) + } + + /// Connects session-manager and initial-session semantic controls. + fn subscribe(&self, window: &mut Window, cx: &mut Context) { + self.subscribe_session(&self.sessions[0], window, cx); + + let subscription: Subscription = cx.subscribe_in(&self.close_session, window, |this, _, event, _, cx| { + if matches!(event, ButtonEvent::Click) { + this.close_active_terminal_session(cx); + } + }); + subscription.detach(); + + let profile_select = self.profile_select.clone(); + let profile_select_for_event = profile_select.clone(); + let subscription: Subscription = cx.subscribe_in(&profile_select, window, move |this, _, event, window, cx| { + let SelectInputEvent::SelectionChange { + selected_id: Some(selected_id), + } = event + else { + return; + }; + let Some(profile) = this.terminal_panel_controls.as_ref().and_then(|controls| { + if selected_id.as_ref() == "builtin-shell" { + Some(DesktopTerminalProfile::Builtin) + } else { + controls + .catalog + .get(selected_id.as_ref()) + .cloned() + .map(DesktopTerminalProfile::Shell) + } + }) else { + return; + }; + this.create_terminal_session(profile, window, cx); + let profile_select = profile_select_for_event.clone(); + cx.defer_in(window, move |_, _, cx| { + profile_select.update(cx, |state, cx| state.clear_selection(cx)); + }); + }); + subscription.detach(); + } + + /// Connects input and tab activation for one session. + fn subscribe_session(&self, session: &ManagedTerminalSession, window: &mut Window, cx: &mut Context) { + let id = session.id; + let terminal = session.terminal.clone(); + let subscription: Subscription = + cx.subscribe_in(&terminal, window, move |this, _, event, window, cx| match *event { + TerminalEmulatorEvent::InputWritten => this.process_builtin_terminal_input(id, window, cx), + TerminalEmulatorEvent::Exited { code } => this.terminal_session_exited(id, code, cx), + }); + subscription.detach(); + + let tab = session.tab.clone(); + let subscription: Subscription = cx.subscribe_in(&tab, window, move |this, _, event, window, cx| { + if matches!(event, ButtonEvent::Click) { + this.activate_terminal_session(id, window, cx); + } + }); + subscription.detach(); + } + + /// Returns the active terminal session. + pub(super) fn active(&self) -> Option<&ManagedTerminalSession> { + self.sessions.iter().find(|session| session.id == self.active_session) + } + + /// Returns a session by stable identity. + pub(super) fn session(&self, id: TerminalSessionId) -> Option<&ManagedTerminalSession> { + self.sessions.iter().find(|session| session.id == id) + } + + /// Returns a mutable session by stable identity. + pub(super) fn session_mut(&mut self, id: TerminalSessionId) -> Option<&mut ManagedTerminalSession> { + self.sessions.iter_mut().find(|session| session.id == id) + } + + /// Returns the focus handle owned by the active terminal surface. + pub(crate) fn focus(&self, cx: &gpui::App) -> Option { + self + .active() + .map(|session| session.terminal.read(cx).focus_handle().clone()) + } +} + +impl ChitinApp { + /// Returns the command host owned by one built-in terminal session. + fn terminal_shell_host(&self, id: TerminalSessionId) -> Option { + self + .terminal_panel_controls + .as_ref()? + .session(id)? + .builtin + .as_ref() + .map(|builtin| builtin.host.clone()) + } + + /// Returns terminal controls, creating and subscribing them on first use. + pub(crate) fn terminal_panel_controls( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if let Some(controls) = self.terminal_panel_controls.as_ref() { + return Some(controls.clone()); + } + + let working_directory = self.terminal_working_directory(); + let shell_context = desktop_shell_context(self.workspace.as_ref().map(|workspace| workspace.root.clone())); + let initial_id = self.terminal_panel.next_session_id(); + let controls = match TerminalPanelControls::new(initial_id, &working_directory, shell_context, cx) { + Ok(controls) => controls, + Err(error) => { + log::error!("failed to create built-in terminal: {error}"); + return None; + } + }; + controls.subscribe(window, cx); + self.terminal_panel_controls = Some(controls.clone()); + Some(controls) + } + + /// Shows or hides the bottom terminal panel. + pub(crate) fn toggle_terminal(&mut self, cx: &mut Context) { + let visible = self.bottom_dock.toggle(TERMINAL_DOCK_ITEM_ID); + self.terminal_panel.request_focus(visible); + cx.notify(); + } + + /// Creates a new terminal session for the selected profile. + fn create_terminal_session(&mut self, profile: DesktopTerminalProfile, window: &mut Window, cx: &mut Context) { + let working_directory = self.terminal_working_directory(); + let shell_context = desktop_shell_context(self.workspace.as_ref().map(|workspace| workspace.root.clone())); + if self.terminal_panel_controls.is_none() { + return; + } + let id = self.terminal_panel.next_session_id(); + let session = match TerminalPanelControls::build_session(id, &profile, &working_directory, shell_context, cx) { + Ok(session) => session, + Err(error) => { + log::error!("failed to create {} terminal session: {error}", profile.label()); + self.show_toast( + Toast::new(format!("Could not start {}", profile.label())) + .description(error.to_string()) + .variant(ToastVariant::Error), + cx, + ); + return; + } + }; + if let Some(controls) = self.terminal_panel_controls.as_ref() { + controls.subscribe_session(&session, window, cx); + } + if let Some(controls) = self.terminal_panel_controls.as_mut() { + controls.sessions.push(session); + controls.active_session = id; + // The new tab is the last child, so revealing it keeps the active session in + // view without the session manager having to know the strip's geometry. + controls.tab_scroll.scroll_to_bottom(); + } + self.terminal_panel.request_focus(true); + cx.notify(); + } + + /// Activates one existing session and transfers keyboard focus to it. + fn activate_terminal_session(&mut self, id: TerminalSessionId, window: &mut Window, cx: &mut Context) { + let Some(controls) = self.terminal_panel_controls.as_mut() else { + return; + }; + if controls.session(id).is_none() { + return; + } + controls.active_session = id; + if let Some(focus) = controls.focus(cx) { + window.focus(&focus, cx); + } + cx.notify(); + } + + /// Closes the active session and selects its nearest remaining neighbor. + fn close_active_terminal_session(&mut self, cx: &mut Context) { + let Some(id) = self + .terminal_panel_controls + .as_ref() + .and_then(TerminalPanelControls::active) + .map(|session| session.id) + else { + return; + }; + self.close_terminal_session(id, cx); + } + + /// Retires a session whose terminal backend stopped on its own. + /// + /// A program that exited cleanly has nothing left to show, so its tab goes away with + /// it. One that failed keeps its tab, because the surface is showing the exit status + /// and closing it would discard the last screen along with the reason for it. + fn terminal_session_exited(&mut self, id: TerminalSessionId, code: u32, cx: &mut Context) { + if code == 0 { + self.close_terminal_session(id, cx); + } + } + + /// Closes one session and moves the selection when the active one is gone. + fn close_terminal_session(&mut self, id: TerminalSessionId, cx: &mut Context) { + let builtin = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(id)) + .and_then(|session| session.builtin.clone()); + if let Some(builtin) = builtin + && let Err(error) = builtin.host.session().cancel_active() + { + log::warn!("failed to cancel active built-in terminal command: {error}"); + } + let Some(controls) = self.terminal_panel_controls.as_mut() else { + return; + }; + let Some(index) = controls.sessions.iter().position(|session| session.id == id) else { + return; + }; + let was_active = controls.active_session == id; + controls.sessions.remove(index); + if controls.sessions.is_empty() { + self.terminal_panel_controls = None; + self.bottom_dock.close(); + self.terminal_panel.request_focus(false); + cx.notify(); + return; + } + if was_active { + if let Some(session) = controls.sessions.get(index.min(controls.sessions.len() - 1)) { + controls.active_session = session.id; + } + self.terminal_panel.request_focus(true); + } + cx.notify(); + } + + /// Resolves the directory inherited by newly created terminal sessions. + fn terminal_working_directory(&self) -> PathBuf { + self + .terminal_panel_controls + .as_ref() + .and_then(TerminalPanelControls::active) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| builtin.host.session()) + .unwrap_or_else(|| self.builtin_shell()) + .snapshot() + .map(|snapshot| snapshot.working_directory) + .or_else(|_| std::env::current_dir()) + .unwrap_or_else(|_| PathBuf::from(".")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_session_ids_should_not_repeat_after_a_dock_reopen() { + let mut state = TerminalPanelState::new(); + let first = state.next_session_id(); + let second = state.next_session_id(); + + assert_ne!(first, second); + } + + #[test] + fn terminal_shell_icons_should_resolve_to_existing_assets() { + let assets = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + let all_exist = ["powershell", "pwsh", "zsh", "nushell"] + .into_iter() + .map(terminal_profile_icon) + .all(|icon| assets.join(icon).is_file()); + + assert!(all_exist); + } +} diff --git a/crates/chitin-desktop/src/components/terminal/presenter.rs b/crates/chitin-desktop/src/components/terminal/presenter.rs new file mode 100644 index 00000000..c0638b96 --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/presenter.rs @@ -0,0 +1,156 @@ +//! Translation from frontend-neutral shell records to terminal presentation. + +use std::path::Path; + +use chitin_builtin_shell::{BuiltinShellSnapshot, ShellCommandId, ShellTranscriptContent}; +use chitin_command::{ + CommandMessageLevel, CommandOutcome, CommandOutputTone, CommandReportStatus, render_outcome as render_command_outcome, +}; +use chitin_ui::{ + composite::command_terminal::CommandTerminalStatus, + primitive::terminal::{TerminalLine, TerminalSpan, TerminalTone}, +}; + +/// Builds an ANSI-colored prompt for the in-process terminal program. +pub(super) fn terminal_prompt_ansi(working_directory: &Path) -> String { + format!( + "\x1b[38;2;0;145;220mchitin\x1b[0m \x1b[90m{}\x1b[0m \x1b[38;2;0;145;220m❯\x1b[0m ", + working_directory.display(), + ) +} + +/// Converts one command's structured event stream into terminal rows. +/// +/// # Parameters +/// +/// * `snapshot` supplies the current shell transcript. +/// * `shell_id` selects entries belonging to one submitted command. +/// +/// # Returns +/// +/// Ordered progress, message, artifact, failure, and cancellation rows. +pub(super) fn shell_output_lines(snapshot: &BuiltinShellSnapshot, shell_id: ShellCommandId) -> Vec { + snapshot + .transcript + .iter() + .filter(|entry| entry.command_id == shell_id) + .filter_map(|entry| match &entry.content { + ShellTranscriptContent::Input(_) | ShellTranscriptContent::Completed => None, + ShellTranscriptContent::Progress(progress) => { + let stage = progress + .stage_label + .clone() + .unwrap_or_else(|| format!("Stage {}/{}", progress.stage_index, progress.stage_count)); + let amount = progress.total.map_or_else( + || progress.completed.to_string(), + |total| format!("{} / {}", progress.completed, total), + ); + Some(TerminalLine::new([TerminalSpan::info(format!("{stage} {amount}"))])) + } + ShellTranscriptContent::Message(message) => Some(TerminalLine::new([TerminalSpan::new( + message.text.clone(), + match message.level { + CommandMessageLevel::Info => TerminalTone::Info, + CommandMessageLevel::Warning => TerminalTone::Warning, + }, + )])), + ShellTranscriptContent::Artifact(artifact) => Some(TerminalLine::new([ + TerminalSpan::secondary("→ "), + TerminalSpan::primary(format!("{} ({} bytes)", artifact.path.display(), artifact.bytes)), + ])), + ShellTranscriptContent::Failed(message) => { + Some(TerminalLine::new([TerminalSpan::error(format!("error: {message}"))])) + } + ShellTranscriptContent::Cancelled => Some(TerminalLine::new([TerminalSpan::warning("^C cancelled")])), + }) + .collect() +} + +/// Converts a portable command result into rows placed before the next prompt. +/// +/// # Parameters +/// +/// * `outcome` is the structured result returned by the shared command runtime. +/// +/// # Returns +/// +/// The terminal status and final rows for the corresponding command block. +pub(super) fn terminal_outcome(outcome: CommandOutcome) -> (CommandTerminalStatus, Vec) { + let report = render_command_outcome(&outcome); + let status = match report.status { + CommandReportStatus::Succeeded => CommandTerminalStatus::Succeeded, + CommandReportStatus::Failed => CommandTerminalStatus::Failed, + }; + let lines = report + .lines + .into_iter() + .map(|line| { + TerminalLine::new( + line + .spans + .into_iter() + .map(|span| TerminalSpan::new(span.text, terminal_tone(span.tone))), + ) + }) + .collect(); + (status, lines) +} + +/// Maps a shared output tone to the command terminal theme. +fn terminal_tone(tone: CommandOutputTone) -> TerminalTone { + match tone { + CommandOutputTone::Primary => TerminalTone::Primary, + CommandOutputTone::Secondary => TerminalTone::Secondary, + CommandOutputTone::Heading => TerminalTone::Accent, + CommandOutputTone::Success => TerminalTone::Success, + CommandOutputTone::Warning => TerminalTone::Warning, + CommandOutputTone::Error => TerminalTone::Error, + } +} + +/// Converts semantic terminal lines into ANSI-colored program output. +pub(super) fn terminal_lines_ansi(lines: &[TerminalLine]) -> String { + lines + .iter() + .map(|line| { + line + .spans() + .iter() + .map(|span| format!("{}{}\x1b[0m", tone_ansi(span.tone), span.text)) + .collect::() + }) + .collect::>() + .join("\n") +} + +/// Returns the ANSI SGR prefix for a semantic terminal tone. +fn tone_ansi(tone: TerminalTone) -> &'static str { + match tone { + TerminalTone::Primary => "\x1b[39m", + TerminalTone::Secondary => "\x1b[90m", + TerminalTone::Accent | TerminalTone::Info => "\x1b[36m", + TerminalTone::Success => "\x1b[32m", + TerminalTone::Warning => "\x1b[33m", + TerminalTone::Error => "\x1b[31m", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_adapter_should_preserve_shared_report_text() { + let outcome = CommandOutcome::DatabaseDownload { artifact_count: 2 }; + let expected = render_command_outcome(&outcome).plain_text(); + + let (_, lines) = terminal_outcome(outcome); + let actual = lines + .iter() + .map(|line| line.spans().iter().map(|span| span.text.as_str()).collect::()) + .collect::>() + .join("\n"); + + assert_eq!(actual, expected); + } +} diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs new file mode 100644 index 00000000..2db98797 --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -0,0 +1,208 @@ +//! Terminal content hosted by the reusable workbench bottom dock. + +use chitin_ui::{ + composite::bottom_dock::{BottomDock, BottomDockResizeConfig}, + primitive::{ + button::{Button, ButtonSize, ButtonState, ButtonStyle, ButtonVariant}, + icon::Icon, + input::select::{ + Select, SelectContent, SelectContentPosition, SelectGroup, SelectInputSize, SelectInputStyle, SelectInputVariant, + SelectItem, SelectTrigger, SelectValue, + }, + terminal::TerminalEmulator, + }, + themes::{UIThemes, builtins}, +}; +use gpui::{ + Entity, InteractiveElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, WeakEntity, div, + prelude::FluentBuilder, px, +}; + +use super::{TerminalPanelControls, terminal_profile_icon}; +use crate::{app::ChitinApp, fonts::TERMINAL_FONT_FAMILY, keybindings::COMMAND_TERMINAL_KEY_CONTEXT}; + +/// Renders the VT terminal emulator as the active bottom-dock item. +/// +/// # Parameters +/// +/// * `controls` contains the persistent emulator and in-process shell endpoint. +/// * `close` is the bottom dock's shared close-button state. +/// * `height` is the current workbench-level dock height. +/// * `theme` supplies semantic colors for the dock and terminal. +/// * `app` receives resize and keyboard events from the rendered content. +/// +/// # Returns +/// +/// A bottom-dock element containing the shared VT terminal surface. +pub(crate) fn render_terminal_bottom_dock( + controls: TerminalPanelControls, + close: Entity, + height: Pixels, + theme: UIThemes, + app: WeakEntity, +) -> impl gpui::IntoElement { + let resize_app = app.clone(); + let resize_move_app = app.clone(); + let resize_end_app = app.clone(); + let active_terminal = controls.active().map(|session| session.terminal.clone()); + let tab_scroll = controls.tab_scroll.clone(); + let tab_strip = Styled::scrollbar_width( + div() + .id("terminal-session-tabs") + .flex() + .flex_col() + .gap_1() + .w(px(38.0)) + .min_h_0() + .p_1() + .border_l_1() + .border_color(theme.border.muted) + .overflow_y_scroll() + .track_scroll(&tab_scroll), + // The lane is only wide enough for one icon column, so the native bar would + // cover the tabs themselves. + px(0.0), + ); + let sessions = controls.sessions.iter().fold(tab_strip, |sessions, session| { + let selected = session.id == controls.active_session; + sessions.child( + Button::new(session.tab.clone()) + .size(ButtonSize::Small) + .variant(ButtonVariant::Transparent) + .style( + ButtonStyle::new() + .width(px(28.0)) + .height(px(28.0)) + .horizontal_padding(px(0.0)) + .background(if selected { + theme.background.selection + } else { + builtins::TRANSPARENT + }), + ) + .theme(theme) + .child( + Icon::new(terminal_profile_icon(session.profile.id())) + .size(px(14.0)) + .color(if selected { + theme.text.primary + } else { + theme.text.secondary + }) + .hover_color(theme.text.primary) + .theme(theme), + ), + ) + }); + let header_actions = div() + .flex() + .items_center() + .gap_1() + .child(new_terminal_select( + controls.profile_select.clone(), + &controls.catalog, + theme, + )) + .child( + Button::new(controls.close_session.clone()) + .size(ButtonSize::Small) + .variant(ButtonVariant::Transparent) + .style( + ButtonStyle::new() + .width(px(26.0)) + .height(px(24.0)) + .horizontal_padding(px(0.0)), + ) + .theme(theme) + .child(Icon::new("icons/terminal-trash.svg").size(px(14.0)).theme(theme)), + ); + let terminal = div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .key_context(COMMAND_TERMINAL_KEY_CONTEXT) + .child( + div() + .flex() + .flex_row() + .flex_1() + .min_h_0() + .child(div().flex().flex_col().flex_1().min_w_0().min_h_0().p_2().when_some( + active_terminal, + |terminal, active_terminal| { + terminal.child( + TerminalEmulator::new(active_terminal) + .theme(theme) + .font_family(TERMINAL_FONT_FAMILY), + ) + }, + )) + .child(sessions), + ); + + BottomDock::new("TERMINAL", close) + .height(height) + .theme(theme) + .header_actions(header_actions) + .resizable( + BottomDockResizeConfig::new(move |start_y, _, cx| { + let _ = resize_app.update(cx, |this, cx| { + this.bottom_dock.start_resize(start_y); + cx.notify(); + }); + }) + .on_resize(move |current_y, window, cx| { + let available_height = ChitinApp::document_panel_root_height(window.bounds().size.height); + let _ = resize_move_app.update(cx, |this, cx| { + if this.bottom_dock.drag_resize(current_y, available_height) { + cx.notify(); + } + }); + }) + .on_resize_end(move |_, cx| { + let _ = resize_end_app.update(cx, |this, cx| { + if this.bottom_dock.stop_resize() { + cx.notify(); + } + }); + }), + ) + .child(terminal) +} + +/// Builds the icon-only selector used to create a terminal profile session. +fn new_terminal_select( + profile_select: Entity, + catalog: &chitin_terminal::ShellCatalog, + theme: UIThemes, +) -> impl gpui::IntoElement { + let group = catalog.available().iter().fold( + SelectGroup::new() + .item(SelectItem::new("builtin-shell", "Built-in shell").icon(terminal_profile_icon("builtin-shell"))), + |group, shell| group.item(SelectItem::new(&shell.id, &shell.label).icon(terminal_profile_icon(&shell.id))), + ); + Select::new(profile_select) + .trigger( + SelectTrigger::new() + .value(SelectValue::new().placeholder("")) + .icon("icons/terminal-add.svg") + .show_indicator(false), + ) + .content( + SelectContent::new() + .position(SelectContentPosition::Popper) + .align_end() + .group(group), + ) + .variant(SelectInputVariant::Transparent) + .size(SelectInputSize::Small) + .style( + SelectInputStyle::new() + .width(px(28.0)) + .trigger_padding_x(px(6.0)) + .trigger_icon_size(px(16.0)) + .menu_width(px(180.0)), + ) + .theme(theme) +} diff --git a/crates/chitin-desktop/src/components/wgpu_panel.rs b/crates/chitin-desktop/src/components/wgpu_panel.rs index d3298e4d..63ee79ad 100644 --- a/crates/chitin-desktop/src/components/wgpu_panel.rs +++ b/crates/chitin-desktop/src/components/wgpu_panel.rs @@ -1,13 +1,10 @@ //! Experimental WGPU document panel component. -use std::{ - sync::Arc, - time::{Duration, Instant}, -}; +use std::time::{Duration, Instant}; use chitin_bio::surface::MolecularSurfaceArtifact; use chitin_molecule_renderer::{DragMode, RepresentationLayers, ViewerCamera, ViewportDrag}; -use chitin_wgpu::{ClearRenderer, RenderTargetSize}; +use chitin_wgpu::RenderTargetSize; use gpui::{ Context, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Render, ScrollWheelEvent, WgpuSurfaceHandle, Window, div, prelude::*, px, rgb, wgpu_surface, @@ -73,49 +70,6 @@ pub trait WgpuPanelScene { } } -/// Default scene used when no specialized renderer is supplied. -struct ClearScene { - /// Lazy renderer using the surface's device and queue. - renderer: Option, -} - -impl ClearScene { - /// Creates a scene that clears the WGPU surface each frame. - fn new() -> Self { - Self { renderer: None } - } -} - -impl WgpuPanelScene for ClearScene { - /// Clears the current frame using the shared WGPU helper. - /// - /// # Parameters - /// - /// * `frame` contains the GPUI surface resources and back-buffer target. - /// - /// # Returns - /// - /// The queue submission index for synchronized presentation. - fn render_frame(&mut self, frame: WgpuPanelFrame<'_>) -> wgpu::SubmissionIndex { - let renderer = self.renderer.get_or_insert_with(|| { - ClearRenderer::new( - Arc::new(frame.device.clone()), - Arc::new(frame.queue.clone()), - frame.size, - wgpu::Color { - r: 0.025, - g: 0.030, - b: 0.045, - a: 1.0, - }, - ) - }); - - renderer.resize_if_needed(frame.size); - renderer.render(frame.view) - } -} - /// Experimental interactive WGPU panel suitable for a document-area tab. pub struct ChitinWgpuDocumentPanel { /// GPUI surface handle, absent only if the backend cannot create WGPU surfaces. @@ -137,20 +91,6 @@ pub struct ChitinWgpuDocumentPanel { } impl ChitinWgpuDocumentPanel { - /// Creates an interactive WGPU document panel. - /// - /// # Parameters - /// - /// * `surface` is the GPUI-owned WGPU surface created by the desktop window. - /// - /// # Returns - /// - /// A panel ready to render when GPUI schedules its first frame. - #[allow(dead_code)] - pub fn new(surface: Option) -> Self { - Self::new_with_scene(surface, ClearScene::new()) - } - /// Creates an interactive WGPU document panel with a custom scene. /// /// # Parameters diff --git a/crates/chitin-desktop/src/fonts.rs b/crates/chitin-desktop/src/fonts.rs new file mode 100644 index 00000000..31b84db4 --- /dev/null +++ b/crates/chitin-desktop/src/fonts.rs @@ -0,0 +1,24 @@ +use std::borrow::Cow; + +use gpui::{App, Result}; + +/// Font family used by native and built-in terminal surfaces. +/// +/// Cascadia Mono is the official non-ligature variant of Cascadia Code. +pub const TERMINAL_FONT_FAMILY: &str = "Cascadia Mono"; + +/// Registers the bundled terminal font faces with GPUI. +/// +/// # Parameters +/// +/// * `cx` provides the process-wide GPUI text system. +/// +/// # Returns +/// +/// `Ok(())` after the regular and bold upright faces are registered. +pub fn register_terminal_fonts(cx: &mut App) -> Result<()> { + cx.text_system().add_fonts(vec![ + Cow::Borrowed(include_bytes!("../../../assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf").as_slice()), + Cow::Borrowed(include_bytes!("../../../assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf").as_slice()), + ]) +} diff --git a/crates/chitin-desktop/src/keybindings/application.rs b/crates/chitin-desktop/src/keybindings/application.rs index e7581dd5..2b6d9427 100644 --- a/crates/chitin-desktop/src/keybindings/application.rs +++ b/crates/chitin-desktop/src/keybindings/application.rs @@ -2,17 +2,26 @@ use gpui::{KeyBinding, actions}; use super::CommandShortcut; +pub(crate) const COMMAND_TERMINAL_KEY_CONTEXT: &str = "CommandTerminal"; +pub(crate) const WORKBENCH_KEY_CONTEXT: &str = "Workbench"; const TOGGLE_COMMAND_PANEL_SHORTCUTS: [CommandShortcut; 1] = [CommandShortcut::new("secondary-shift-p", "Ctrl/Cmd+Shift+P", None)]; +const TOGGLE_TERMINAL_SHORTCUTS: [CommandShortcut; 1] = + [CommandShortcut::new("shift-t", "Shift+T", Some("!CommandTerminal"))]; actions!( application, [ /// Show or hide the command panel. ToggleCommandPanel, + /// Show or hide the built-in terminal panel. + ToggleTerminal, ] ); -pub(super) fn default_key_bindings() -> [KeyBinding; 1] { - [TOGGLE_COMMAND_PANEL_SHORTCUTS[0].binding(ToggleCommandPanel)] +pub(super) fn default_key_bindings() -> [KeyBinding; 2] { + [ + TOGGLE_COMMAND_PANEL_SHORTCUTS[0].binding(ToggleCommandPanel), + TOGGLE_TERMINAL_SHORTCUTS[0].binding(ToggleTerminal), + ] } diff --git a/crates/chitin-desktop/src/keybindings/dispatch.rs b/crates/chitin-desktop/src/keybindings/dispatch.rs index bfb17038..798ff754 100644 --- a/crates/chitin-desktop/src/keybindings/dispatch.rs +++ b/crates/chitin-desktop/src/keybindings/dispatch.rs @@ -1,8 +1,6 @@ //! Desktop execution adapters for shared commands. -use chitin_command::{ - ApplicationCommand, ChitinCommand, DatabaseCommand, PanelTabCommand, StructureCommand, WorkspaceCommand, -}; +use chitin_command::{ApplicationCommand, FrontendCommand, PanelTabCommand, WorkspaceCommand}; use gpui::{Context, Window}; use crate::{app::ChitinApp, components::workspace_tree::WorkspaceTreeNavigation}; @@ -33,14 +31,15 @@ impl ChitinApp { /// the regular context-only dispatch path. pub(crate) fn dispatch_command_with_window( &mut self, - command: ChitinCommand, + command: FrontendCommand, window: &mut Window, cx: &mut Context, ) { - if matches!(command, ChitinCommand::Workspace(WorkspaceCommand::ActivateFocused)) { - self.activate_focused_project_tree_entry_with_window(window, cx); - } else { - self.dispatch_command(command, cx); + match command { + FrontendCommand::Workspace(WorkspaceCommand::ActivateFocused) => { + self.activate_focused_project_tree_entry_with_window(window, cx); + } + command => self.dispatch_command(command, cx), } } @@ -54,22 +53,15 @@ impl ChitinApp { /// # Returns /// /// This function returns `()` after routing the command to its feature handler. - pub(crate) fn dispatch_command(&mut self, command: ChitinCommand, cx: &mut Context) { + pub(crate) fn dispatch_command(&mut self, command: FrontendCommand, cx: &mut Context) { log::debug!("Dispatch command {}", command.id()); match command { - ChitinCommand::Workspace(command) => self.dispatch_workspace_command(command, cx), - ChitinCommand::Database(command) => self.dispatch_database_command(command, cx), - ChitinCommand::Application(command) => self.dispatch_application_command(command, cx), - ChitinCommand::Structure(command) => self.dispatch_structure_command(command), + FrontendCommand::Workspace(command) => self.dispatch_workspace_command(command, cx), + FrontendCommand::Application(command) => self.dispatch_application_command(command, cx), } } - /// Reports structure commands that are currently CLI-only. - pub(crate) fn dispatch_structure_command(&mut self, command: StructureCommand) { - log::debug!("Structure command {} is not a desktop action yet", command.id()); - } - /// Executes an application command. /// /// # Parameters @@ -83,27 +75,7 @@ impl ChitinApp { pub(crate) fn dispatch_application_command(&mut self, command: ApplicationCommand, cx: &mut Context) { match command { ApplicationCommand::ToggleCommandPanel => self.toggle_command_panel(cx), - } - } - - /// Executes a database command. - /// - /// # Parameters - /// - /// * `command` identifies the database workflow to start. - /// * `cx` is the GPUI context notified when the form is opened. - /// - /// # Returns - /// - /// This function returns `()` after opening the corresponding form. - pub(crate) fn dispatch_database_command(&mut self, command: DatabaseCommand, cx: &mut Context) { - match command { - DatabaseCommand::DownloadRcsbStructure => { - let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure); - if self.command_panel.open_form(command) { - cx.notify(); - } - } + ApplicationCommand::ToggleTerminal => self.toggle_terminal(cx), } } diff --git a/crates/chitin-desktop/src/keybindings/mod.rs b/crates/chitin-desktop/src/keybindings/mod.rs index a11d04da..9d30471e 100644 --- a/crates/chitin-desktop/src/keybindings/mod.rs +++ b/crates/chitin-desktop/src/keybindings/mod.rs @@ -5,7 +5,7 @@ mod dispatch; mod tab; mod workspace; -pub(crate) use application::ToggleCommandPanel; +pub(crate) use application::{COMMAND_TERMINAL_KEY_CONTEXT, ToggleCommandPanel, ToggleTerminal, WORKBENCH_KEY_CONTEXT}; pub(crate) use tab::{CloseTab, FocusNextPanelTab, FocusPreviousPanelTab, PANEL_CONTAINER_KEY_CONTEXT}; pub(crate) use workspace::{ ActivateFocusedEntry, FocusFirstEntry, FocusLastEntry, FocusNextEntry, FocusPreviousEntry, PROJECT_TREE_KEY_CONTEXT, diff --git a/crates/chitin-desktop/src/lib.rs b/crates/chitin-desktop/src/lib.rs index fa7230d1..46b972f4 100644 --- a/crates/chitin-desktop/src/lib.rs +++ b/crates/chitin-desktop/src/lib.rs @@ -5,8 +5,11 @@ //! validate the real desktop shell without `#[path]`-based source inclusion. pub mod app; +pub mod builtin_shell; pub(crate) mod components; +pub mod fonts; pub mod keybindings; +pub mod portable_command; pub mod tasks; /// GPUI adapter for the experimental WGPU document panel. diff --git a/crates/chitin-desktop/src/main.rs b/crates/chitin-desktop/src/main.rs index 98371dc5..3bb3373c 100644 --- a/crates/chitin-desktop/src/main.rs +++ b/crates/chitin-desktop/src/main.rs @@ -3,7 +3,7 @@ use std::{borrow::Cow, collections::BTreeSet, path::PathBuf}; -use chitin_desktop::{app::ChitinApp, keybindings::default_key_bindings}; +use chitin_desktop::{app::ChitinApp, fonts::register_terminal_fonts, keybindings::default_key_bindings}; use gpui::{ App, AppContext, Application, AssetSource, Bounds, Result, SharedString, WindowBounds, WindowOptions, px, size, }; @@ -12,6 +12,7 @@ use rust_embed::RustEmbed; /// Compile-time asset bundle for the desktop application. #[derive(RustEmbed)] #[folder = "$CARGO_MANIFEST_DIR/../../assets"] +#[exclude = "fonts/**"] struct EmbeddedAssets; /// GPUI asset source backed by the embedded desktop assets. @@ -68,6 +69,11 @@ fn main() { let project_path = std::env::args_os().nth(1).map(PathBuf::from); Application::new().with_assets(DesktopAssets).run(|cx: &mut App| { + if let Err(error) = register_terminal_fonts(cx) { + eprintln!("failed to register bundled terminal fonts: {error}"); + cx.quit(); + return; + } cx.bind_keys(default_key_bindings()); let bounds = Bounds::centered(None, size(px(1100.0), px(760.0)), cx); diff --git a/crates/chitin-desktop/src/portable_command.rs b/crates/chitin-desktop/src/portable_command.rs new file mode 100644 index 00000000..11bfd97f --- /dev/null +++ b/crates/chitin-desktop/src/portable_command.rs @@ -0,0 +1,288 @@ +//! Desktop adapter for executing portable commands as background tasks. + +use chitin_command::{ + CommandEventSink, CommandExecutionContext, CommandExecutionError, CommandExecutor, CommandOutcome, DatabaseCommand, + PortableCommand, StructureCommand, resolve_rcsb_download_paths, +}; +use chitin_databases::CancellationToken; +use tokio::sync::oneshot; + +use crate::tasks::{BackgroundTaskCenter, TaskCenterError, TaskFailure, TaskHandle, TaskKind, TaskTarget}; + +/// Terminal state reported to the frontend that originated a portable task. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PortableCommandCompletion { + /// The command completed and produced a typed outcome. + Succeeded, + /// Cooperative cancellation was requested before execution finished. + Cancelled, + /// Execution failed without a cancellation request. + Failed(String), +} + +/// Complete desktop submission parameters for one portable command. +pub struct PortableCommandSubmission { + kind: TaskKind, + command: PortableCommand, + context: CommandExecutionContext, + cancellation: CancellationToken, + events: CommandEventSink, + completion: Option>, +} + +impl PortableCommandSubmission { + /// Creates a submission with silent frontend events and context cancellation. + pub fn new(kind: TaskKind, command: PortableCommand, context: CommandExecutionContext) -> Self { + let cancellation = context.cancellation.clone(); + Self { + kind, + command, + context, + cancellation, + events: CommandEventSink::silent(), + completion: None, + } + } + + /// Replaces the cancellation token shared with the task center. + pub fn with_cancellation(mut self, cancellation: CancellationToken) -> Self { + self.cancellation = cancellation; + self + } + + /// Forwards executor events to an additional frontend observer. + pub fn with_events(mut self, events: CommandEventSink) -> Self { + self.events = events; + self + } + + /// Installs a callback invoked exactly once when execution terminates. + pub fn on_completion(mut self, completion: impl FnOnce(PortableCommandCompletion) + Send + 'static) -> Self { + self.completion = Some(Box::new(completion)); + self + } +} + +/// Application-wide adapter from typed portable commands to background tasks. +#[derive(Clone)] +pub struct DesktopPortableCommandRunner { + executor: CommandExecutor, +} + +impl DesktopPortableCommandRunner { + /// Creates a runner around the shared frontend-independent executor. + pub fn new(executor: CommandExecutor) -> Self { + Self { executor } + } + + /// Resolves protected targets and submits a portable command. + /// + /// # Parameters + /// + /// * `center` owns the application background runtime and task registry. + /// * `submission` contains the command, execution context, task metadata, + /// event observer, and optional lifecycle callback. + /// + /// # Returns + /// + /// A running task with its observation handle and typed outcome receiver. + /// + /// # Errors + /// + /// Returns [`DesktopPortableCommandError`] when command targets cannot be + /// resolved or the task center rejects the submission. + pub fn submit( + &self, + center: &BackgroundTaskCenter, + submission: PortableCommandSubmission, + ) -> Result { + let PortableCommandSubmission { + kind, + command, + context, + cancellation, + events, + completion, + } = submission; + let targets = command_targets(&command, &context)?; + let target_count = targets.len(); + let executor = self.executor.clone(); + let execution_cancellation = cancellation.clone(); + let (result_sender, result_receiver) = oneshot::channel(); + let task = center.submit_with_cancellation(kind, targets, cancellation, move |task_context| async move { + let event_context = task_context.clone(); + let task_events = CommandEventSink::new(move |event| { + event_context.report_command_event(event.clone()); + events.emit(event); + }); + let execution_context = context.with_cancellation(task_context.cancellation_token()); + let result = executor.execute(command, execution_context, task_events).await; + match result { + Ok(_) if execution_cancellation.is_cancelled() => { + finish_submission(completion, PortableCommandCompletion::Cancelled); + let _ = result_sender.send(PortableCommandResult::Cancelled); + Ok(()) + } + Ok(outcome) => { + finish_submission(completion, PortableCommandCompletion::Succeeded); + let _ = result_sender.send(PortableCommandResult::Succeeded(outcome)); + Ok(()) + } + Err(error) if execution_cancellation.is_cancelled() => { + let failure = TaskFailure::new(error.to_string()); + finish_submission(completion, PortableCommandCompletion::Cancelled); + let _ = result_sender.send(PortableCommandResult::Cancelled); + Err(failure) + } + Err(error) => { + let message = error.to_string(); + finish_submission(completion, PortableCommandCompletion::Failed(message.clone())); + let _ = result_sender.send(PortableCommandResult::Failed(error)); + Err(TaskFailure::new(message)) + } + } + })?; + Ok(DesktopPortableCommandTask { + task, + target_count, + result: result_receiver, + }) + } +} + +/// Running portable command exposed to desktop presenters and protocol hosts. +pub struct DesktopPortableCommandTask { + task: TaskHandle, + target_count: usize, + result: oneshot::Receiver, +} + +impl DesktopPortableCommandTask { + /// Returns the background task observation handle. + pub const fn task(&self) -> &TaskHandle { + &self.task + } + + /// Returns the number of resources reserved by the task. + pub const fn target_count(&self) -> usize { + self.target_count + } + + /// Consumes the result receiver and returns the task observation handle. + pub fn into_task_handle(self) -> TaskHandle { + self.task + } + + /// Waits for and returns the typed executor outcome. + pub async fn result(self) -> Result { + match self + .result + .await + .map_err(|_| DesktopPortableCommandError::ResultChannelClosed)? + { + PortableCommandResult::Succeeded(outcome) => Ok(outcome), + PortableCommandResult::Cancelled => Err(DesktopPortableCommandError::Cancelled), + PortableCommandResult::Failed(error) => Err(DesktopPortableCommandError::Execution(error)), + } + } +} + +/// Typed terminal result transferred from the task runtime to its caller. +enum PortableCommandResult { + /// Execution completed with a domain outcome. + Succeeded(CommandOutcome), + /// Cooperative cancellation won the terminal-state race. + Cancelled, + /// Execution failed before producing an outcome. + Failed(CommandExecutionError), +} + +/// Failure while preparing, submitting, or observing a portable desktop task. +#[derive(Debug, thiserror::Error)] +pub enum DesktopPortableCommandError { + /// Portable command preparation or execution failed. + #[error(transparent)] + Execution(#[from] CommandExecutionError), + /// The application task center rejected the command. + #[error(transparent)] + TaskCenter(#[from] TaskCenterError), + /// The task ended without publishing its typed outcome. + #[error("portable command result channel closed before completion")] + ResultChannelClosed, + /// Cooperative cancellation completed before a result was presented. + #[error("portable command was cancelled")] + Cancelled, +} + +/// Notifies an optional origin-specific lifecycle observer. +fn finish_submission( + completion: Option>, + state: PortableCommandCompletion, +) { + if let Some(completion) = completion { + completion(state); + } +} + +/// Resolves resources protected while a portable command is active. +fn command_targets( + command: &PortableCommand, + context: &CommandExecutionContext, +) -> Result, CommandExecutionError> { + match command { + PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure(arguments)) => { + resolve_rcsb_download_paths(arguments, context).map(|paths| paths.into_iter().map(TaskTarget::File).collect()) + } + PortableCommand::Structure(StructureCommand::Inspect(arguments)) => { + Ok(vec![TaskTarget::File(arguments.input.input.clone())]) + } + PortableCommand::Structure(StructureCommand::Validate(arguments)) => { + Ok(vec![TaskTarget::File(arguments.input.input.clone())]) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use chitin_command::{CommandOutputFormat, StructureInputArguments, StructureValidateArguments}; + use chitin_databases::{ClientConfig, providers::rcsb::StructureFormat}; + + use super::*; + + #[test] + fn runner_should_execute_structure_validation_and_publish_result() -> Result<(), Box> { + let center = BackgroundTaskCenter::new(); + let runner = DesktopPortableCommandRunner::new(CommandExecutor::new(ClientConfig::default())); + let command = StructureCommand::Validate(StructureValidateArguments { + input: StructureInputArguments { + input: PathBuf::from("-"), + format: Some(StructureFormat::Pdb), + }, + output: CommandOutputFormat::Text, + }); + let context = CommandExecutionContext::new(".").with_standard_input( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), + ); + let task = runner.submit( + ¢er, + PortableCommandSubmission::new( + TaskKind::BuiltinShell { + command_id: command.id(), + }, + command.into(), + context, + ), + )?; + let runtime = tokio::runtime::Builder::new_current_thread().build()?; + + let outcome = runtime.block_on(task.result())?; + + assert!(matches!( + outcome, + CommandOutcome::StructureValidation(validation) if validation.is_valid() + )); + Ok(()) + } +} diff --git a/crates/chitin-desktop/src/tasks/mod.rs b/crates/chitin-desktop/src/tasks/mod.rs index 63c7cc54..490741dc 100644 --- a/crates/chitin-desktop/src/tasks/mod.rs +++ b/crates/chitin-desktop/src/tasks/mod.rs @@ -9,6 +9,7 @@ use std::{ sync::{Arc, Mutex}, }; +use chitin_command::{CommandExecutionEvent, CommandId}; use chitin_databases::{CancellationToken, PersistedArtifact}; use futures_util::FutureExt; use tokio::{ @@ -16,8 +17,6 @@ use tokio::{ sync::{broadcast, watch}, }; -pub(crate) mod rcsb; - /// Stable identity assigned to one submitted task. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct TaskId(u64); @@ -33,6 +32,8 @@ impl std::fmt::Display for TaskId { pub enum TaskKind { /// An artifact download from an external database provider. DatabaseDownload { provider: String }, + /// A portable command submitted through the built-in shell bridge. + BuiltinShell { command_id: CommandId }, } /// Lifecycle state shared by all background tasks. @@ -191,6 +192,7 @@ pub enum TaskCenterError { } /// Subscription returned to the UI for one submitted task. +#[derive(Clone)] pub struct TaskHandle { /// Stable identity of the submitted task. id: TaskId, @@ -284,6 +286,22 @@ impl TaskContext { pub fn is_cancelled(&self) -> bool { self.cancellation.is_cancelled() } + + /// Projects one frontend-neutral command event into task-center state. + pub fn report_command_event(&self, event: CommandExecutionEvent) { + match event { + CommandExecutionEvent::Progress(progress) => self.report_progress(TaskProgress { + completed: progress.completed, + total: progress.total, + stage_index: progress.stage_index, + stage_count: progress.stage_count, + stage_label: progress.stage_label, + }), + CommandExecutionEvent::Message(message) => self.log(message.text), + CommandExecutionEvent::Artifact(artifact) => self.output(TaskOutput::PersistedArtifact(artifact)), + CommandExecutionEvent::Started { .. } | CommandExecutionEvent::Completed { .. } => {} + } + } } /// Registry-owned state and notification channel for one task. @@ -413,6 +431,32 @@ impl BackgroundTaskCenter { targets: Vec, task: Task, ) -> Result + where + Task: FnOnce(TaskContext) -> TaskFuture + Send + 'static, + TaskFuture: Future> + Send + 'static, + { + self.submit_with_cancellation(kind, targets, CancellationToken::new(), task) + } + + /// Submits work using a cancellation token owned by an upstream protocol. + /// + /// # Parameters + /// + /// * `kind` identifies the task for global task-center presentation. + /// * `targets` reserve resources against overlapping active work. + /// * `cancellation` is shared with the upstream shell submission. + /// * `task` creates the asynchronous adapter from its reporting context. + /// + /// # Returns + /// + /// A handle observing the task registered with the shared cancellation token. + pub(crate) fn submit_with_cancellation( + &self, + kind: TaskKind, + targets: Vec, + cancellation: CancellationToken, + task: Task, + ) -> Result where Task: FnOnce(TaskContext) -> TaskFuture + Send + 'static, TaskFuture: Future> + Send + 'static, @@ -426,7 +470,6 @@ impl BackgroundTaskCenter { } }; let id = TaskId(self.next_id.fetch_add(1, Ordering::Relaxed)); - let cancellation = CancellationToken::new(); let snapshot = TaskSnapshot { id, kind, diff --git a/crates/chitin-desktop/src/tasks/rcsb.rs b/crates/chitin-desktop/src/tasks/rcsb.rs deleted file mode 100644 index 8dd31b46..00000000 --- a/crates/chitin-desktop/src/tasks/rcsb.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! RCSB database adapter for the application task center. - -use std::collections::BTreeSet; - -use chitin_databases::{ - Client, ClientConfig, - providers::rcsb::{RcsbBatchDownloadEvent, RcsbBatchDownloadRequest, RcsbDownloadError, RcsbError}, -}; - -use super::{ - BackgroundTaskCenter, TaskCenterError, TaskContext, TaskFailure, TaskHandle, TaskKind, TaskOutput, TaskProgress, - TaskTarget, -}; - -/// Submits a batch of RCSB downloads, coalescing repeated destination paths. -pub(crate) fn submit_downloads( - center: &BackgroundTaskCenter, - mut requests: Vec, -) -> Result { - let mut destinations = BTreeSet::new(); - requests.retain(|request| destinations.insert(request.destination.clone())); - let targets = requests - .iter() - .map(|request| TaskTarget::File(request.destination.clone())) - .collect(); - center.submit( - TaskKind::DatabaseDownload { - provider: "RCSB".to_string(), - }, - targets, - move |context| run_downloads(context, requests), - ) -} - -/// Runs the provider batch inside the task center's shared Tokio runtime. -async fn run_downloads(context: TaskContext, requests: Vec) -> Result<(), TaskFailure> { - context.log(format!("downloading {} RCSB structure artifacts", requests.len())); - let client = Client::new(ClientConfig::default()) - .map_err(|error| TaskFailure::new(RcsbDownloadError::Provider(RcsbError::Transport(error)).to_string()))?; - let event_context = context.clone(); - client - .rcsb() - .download_structures_to_paths_with_cancellation( - &requests, - move |event| report_download_event(&event_context, event), - context.cancellation_token(), - ) - .await - .map_err(|error| TaskFailure::new(error.to_string()))?; - Ok(()) -} - -/// Translates provider-specific progress into task-center events. -fn report_download_event(context: &TaskContext, event: RcsbBatchDownloadEvent) { - match event { - RcsbBatchDownloadEvent::Started { index, total, id } => { - context.report_progress(TaskProgress { - completed: 0, - total: None, - stage_index: index, - stage_count: total, - stage_label: Some(id.to_string()), - }); - } - RcsbBatchDownloadEvent::Progress { - index, - total, - id, - received, - total_bytes, - } => { - context.report_progress(TaskProgress { - completed: received, - total: total_bytes, - stage_index: index, - stage_count: total, - stage_label: Some(id.to_string()), - }); - } - RcsbBatchDownloadEvent::Completed { id, artifact, .. } => { - context.log(format!("persisted RCSB structure {id} to {}", artifact.path.display())); - context.output(TaskOutput::PersistedArtifact(artifact)); - } - } -} diff --git a/crates/chitin-terminal/Cargo.toml b/crates/chitin-terminal/Cargo.toml new file mode 100644 index 00000000..7758e641 --- /dev/null +++ b/crates/chitin-terminal/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "chitin-terminal" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Frontend-independent PTY sessions and VT terminal state for Chitin" + +[dependencies] +alacritty_terminal = { git = "https://github.com/l0ng-ai/alacritty", rev = "1276f128fbaa8832cbc66210675cbe8aeb570499" } +async-channel = "2.5" +portable-pty = "0.8" +thiserror = "2.0" + +[lints] +workspace = true diff --git a/crates/chitin-terminal/src/lib.rs b/crates/chitin-terminal/src/lib.rs new file mode 100644 index 00000000..1f0b39f9 --- /dev/null +++ b/crates/chitin-terminal/src/lib.rs @@ -0,0 +1,20 @@ +#![forbid(unsafe_code)] +//! Frontend-independent terminal profiles, PTY and in-process sessions, transcript state, and VT snapshots. + +mod model; +mod session; +mod shell; +mod size; +mod snapshot; + +pub use model::{ + TerminalBlock, TerminalBlockId, TerminalBlockStatus, TerminalBuffer, TerminalLine, TerminalProfile, TerminalSpan, + TerminalTone, +}; +pub use session::{ + TerminalEvent, TerminalProgram, TerminalProgramInput, TerminalProgramOutput, TerminalScroll, TerminalScrollState, + TerminalSession, TerminalSessionError, +}; +pub use shell::{ShellCatalog, ShellDefinition}; +pub use size::TerminalSize; +pub use snapshot::{TerminalCell, TerminalCellAttributes, TerminalColor, TerminalNamedColor, TerminalSnapshot}; diff --git a/crates/chitin-terminal/src/model.rs b/crates/chitin-terminal/src/model.rs new file mode 100644 index 00000000..39fdc76f --- /dev/null +++ b/crates/chitin-terminal/src/model.rs @@ -0,0 +1,343 @@ +//! Frontend-independent terminal transcript state. + +use std::collections::VecDeque; + +/// Backend category hosted by a terminal surface, independent of shell identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum TerminalProfile { + /// Chitin's structured command language and application command executor. + BuiltinShell, + /// The operating system's default interactive shell attached to a native PTY. + SystemShell, +} + +impl TerminalProfile { + /// Reports whether this backend requires an operating-system pseudo-terminal. + pub const fn is_system_shell(self) -> bool { + !matches!(self, Self::BuiltinShell) + } +} + +/// Semantic foreground tone shared by terminal renderers. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TerminalTone { + /// Primary terminal content. + #[default] + Primary, + /// De-emphasized paths, metadata, and hints. + Secondary, + /// Prompt and other interactive accents. + Accent, + /// Successful result content. + Success, + /// Recoverable warning content. + Warning, + /// Failed or rejected command content. + Error, + /// Informational and progress content. + Info, +} + +/// One styled text span in a terminal line. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalSpan { + /// Text rendered by this span. + pub text: String, + /// Semantic foreground tone. + pub tone: TerminalTone, +} + +impl TerminalSpan { + /// Creates a span with a semantic foreground tone. + pub fn new(text: impl Into, tone: TerminalTone) -> Self { + Self { + text: text.into(), + tone, + } + } + + /// Creates primary terminal content. + pub fn primary(text: impl Into) -> Self { + Self::new(text, TerminalTone::Primary) + } + + /// Creates de-emphasized terminal content. + pub fn secondary(text: impl Into) -> Self { + Self::new(text, TerminalTone::Secondary) + } + + /// Creates prompt or interactive accent content. + pub fn accent(text: impl Into) -> Self { + Self::new(text, TerminalTone::Accent) + } + + /// Creates successful terminal content. + pub fn success(text: impl Into) -> Self { + Self::new(text, TerminalTone::Success) + } + + /// Creates warning terminal content. + pub fn warning(text: impl Into) -> Self { + Self::new(text, TerminalTone::Warning) + } + + /// Creates failed or rejected terminal content. + pub fn error(text: impl Into) -> Self { + Self::new(text, TerminalTone::Error) + } + + /// Creates informational and progress content. + pub fn info(text: impl Into) -> Self { + Self::new(text, TerminalTone::Info) + } +} + +/// One soft-wrapping row of terminal spans. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TerminalLine { + spans: Vec, +} + +impl TerminalLine { + /// Creates a line from ordered styled spans. + pub fn new(spans: impl IntoIterator) -> Self { + Self { + spans: spans.into_iter().collect(), + } + } + + /// Creates a line containing primary text. + pub fn plain(text: impl Into) -> Self { + Self::new([TerminalSpan::primary(text)]) + } + + /// Returns the ordered styled spans. + pub fn spans(&self) -> &[TerminalSpan] { + &self.spans + } + + /// Appends one styled span. + pub fn push(&mut self, span: TerminalSpan) { + self.spans.push(span); + } + + /// Returns whether the line contains no text spans. + pub fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + /// Splits embedded newline characters into independently laid-out rows. + pub fn into_rows(self) -> Vec { + if self.spans.iter().all(|span| !span.text.contains('\n')) { + return vec![self]; + } + + let mut rows = Vec::new(); + let mut current = Vec::new(); + for span in self.spans { + for (index, text) in span.text.split('\n').enumerate() { + if index > 0 { + rows.push(Self { + spans: std::mem::take(&mut current), + }); + } + let text = text.strip_suffix('\r').unwrap_or(text); + if !text.is_empty() { + current.push(TerminalSpan::new(text.to_owned(), span.tone)); + } + } + } + rows.push(Self { spans: current }); + rows + } +} + +/// Stable identity for one submitted command block. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TerminalBlockId(u64); + +impl TerminalBlockId { + /// Returns the terminal-local numeric identity. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Presentation lifecycle of one submitted terminal command block. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalBlockStatus { + /// The command is still executing and owns the foreground prompt. + Running, + /// The command completed successfully. + Succeeded, + /// The command execution failed. + Failed, + /// The command stopped through cooperative cancellation. + Cancelled, + /// The submitted line was rejected before execution. + Rejected, +} + +impl TerminalBlockStatus { + /// Returns whether a new live prompt may be rendered. + pub const fn is_terminal(self) -> bool { + !matches!(self, Self::Running) + } +} + +/// One immutable submitted command and its ordered output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalBlock { + /// Terminal-local block identity. + pub id: TerminalBlockId, + /// Prompt captured when this command was submitted. + pub prompt: TerminalLine, + /// Exact command text submitted by the user. + pub input: String, + /// Replaceable executor output such as progress and artifacts. + pub output: Vec, + /// Final structured result lines appended after executor output. + pub result: Vec, + /// Current presentation lifecycle. + pub status: TerminalBlockStatus, +} + +const MAX_TERMINAL_BLOCKS: usize = 512; + +/// Profile-aware transcript model shared by built-in and system terminal hosts. +pub struct TerminalBuffer { + profile: TerminalProfile, + prompt: TerminalLine, + blocks: VecDeque, + active: Option, + next_id: u64, +} + +impl TerminalBuffer { + /// Creates an empty transcript for one terminal profile. + pub fn new(profile: TerminalProfile, prompt: TerminalLine) -> Self { + Self { + profile, + prompt, + blocks: VecDeque::new(), + active: None, + next_id: 1, + } + } + + /// Returns the profile that owns this transcript. + pub const fn profile(&self) -> TerminalProfile { + self.profile + } + + /// Returns submitted command blocks in display order. + pub fn blocks(&self) -> &VecDeque { + &self.blocks + } + + /// Returns the prompt used for the next submission. + pub fn prompt(&self) -> &TerminalLine { + &self.prompt + } + + /// Returns whether a command currently owns the foreground slot. + pub const fn is_busy(&self) -> bool { + self.active.is_some() + } + + /// Returns whether one block currently owns the foreground slot. + pub fn is_active(&self, id: TerminalBlockId) -> bool { + self.active == Some(id) + } + + /// Freezes the live prompt into a running command block. + pub fn begin_submission(&mut self, input: String) -> TerminalBlockId { + let id = self.reserve_block(); + self.blocks.push_back(TerminalBlock { + id, + prompt: self.prompt.clone(), + input, + output: Vec::new(), + result: Vec::new(), + status: TerminalBlockStatus::Running, + }); + self.active = Some(id); + id + } + + /// Appends a completed notice without claiming the foreground slot. + pub fn push_notice(&mut self, input: String, result: Vec) { + let id = self.reserve_block(); + self.blocks.push_back(TerminalBlock { + id, + prompt: self.prompt.clone(), + input, + output: Vec::new(), + result, + status: TerminalBlockStatus::Succeeded, + }); + } + + /// Replaces transient output for one command block. + pub fn set_output(&mut self, id: TerminalBlockId, output: Vec) { + if let Some(block) = self.blocks.iter_mut().find(|block| block.id == id) { + block.output = output; + } + } + + /// Finishes one command block and restores the live prompt. + pub fn finish(&mut self, id: TerminalBlockId, status: TerminalBlockStatus, result: Vec) { + let Some(block) = self.blocks.iter_mut().find(|block| block.id == id) else { + return; + }; + block.status = status; + block.result = result; + if self.active == Some(id) { + self.active = None; + } + } + + /// Clears submitted blocks while retaining the profile and prompt. + pub fn clear(&mut self) { + self.blocks.clear(); + self.active = None; + } + + /// Replaces the prompt used by subsequent command blocks. + pub fn set_prompt(&mut self, prompt: TerminalLine) { + self.prompt = prompt; + } + + /// Reserves the next block identity and evicts the oldest block at capacity. + fn reserve_block(&mut self) -> TerminalBlockId { + let id = TerminalBlockId(self.next_id); + self.next_id = self.next_id.saturating_add(1); + if self.blocks.len() == MAX_TERMINAL_BLOCKS { + self.blocks.pop_front(); + } + id + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_profiles_distinguish_in_process_and_native_shells() { + assert!(!TerminalProfile::BuiltinShell.is_system_shell()); + assert!(TerminalProfile::SystemShell.is_system_shell()); + } + + #[test] + fn buffer_should_preserve_profile_and_command_lifecycle() { + let mut buffer = TerminalBuffer::new(TerminalProfile::BuiltinShell, TerminalLine::plain("chitin ❯ ")); + let id = buffer.begin_submission("help".into()); + assert!(buffer.is_busy()); + buffer.finish(id, TerminalBlockStatus::Succeeded, vec![TerminalLine::plain("ok")]); + + assert_eq!(buffer.profile(), TerminalProfile::BuiltinShell); + assert!(!buffer.is_busy()); + assert_eq!(buffer.blocks()[0].status, TerminalBlockStatus::Succeeded); + } +} diff --git a/crates/chitin-terminal/src/session.rs b/crates/chitin-terminal/src/session.rs new file mode 100644 index 00000000..467b8d62 --- /dev/null +++ b/crates/chitin-terminal/src/session.rs @@ -0,0 +1,941 @@ +use std::{ + io::{Read, Write}, + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, +}; + +use alacritty_terminal::{ + Term, + event::{Event as AlacrittyEvent, EventListener}, + grid::{Dimensions, Scroll}, + sync::FairMutex, + term::{Config, TermMode}, + vte::ansi, +}; +use portable_pty::{ChildKiller, CommandBuilder, MasterPty, native_pty_system}; +use thiserror::Error; + +use crate::{ShellDefinition, TerminalProfile, TerminalSize, TerminalSnapshot}; + +type SharedWriter = Arc>>; +type SharedTerminal = Arc>>; +type SharedProcessor = Arc>>; + +/// Sends terminal events and coalesces UI wakeups independently of event payloads. +#[derive(Clone)] +struct TerminalEventSender { + events: mpsc::Sender, + wake: async_channel::Sender<()>, +} + +impl TerminalEventSender { + /// Queues an event before waking its observer, so the observer can drain it immediately. + fn send(&self, event: TerminalEvent) { + if self.events.send(event).is_ok() { + // One pending wake is enough: the observer drains every queued event together. + let _ = self.wake.try_send(()); + } + } +} + +/// Input sent from a terminal emulator to an in-process terminal program. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TerminalProgramInput { + /// Keyboard, paste, or terminal-response bytes. + Bytes(Vec), + /// Updated character-grid and cell-pixel dimensions. + Resize(TerminalSize), + /// The owning terminal session is shutting down. + Shutdown, +} + +/// A state change emitted by a native terminal session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TerminalEvent { + /// Visible terminal cells changed. + Render, + /// The child changed the terminal title. + Title(String), + /// The terminal emitted an audible or visual bell. + Bell, + /// The child process exited with the supplied exit code. + Exited(u32), + /// A background terminal operation failed. + Error(String), +} + +/// A viewport motion through the terminal's scrollback history. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalScroll { + /// Moves the viewport by whole lines, negative toward the live edge. + Lines(i32), + /// Moves the viewport back by one screenful. + PageUp, + /// Moves the viewport toward the live edge by one screenful. + PageDown, + /// Moves to the oldest line still retained. + Top, + /// Moves to the live edge, where new output appears. + Bottom, + /// Moves so that `lines` of history sit above the viewport. + Offset(usize), +} + +/// Where a terminal viewport sits inside its scrollback history. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TerminalScrollState { + /// Lines of scrollback retained behind the viewport. + pub history_size: usize, + /// Lines of history the viewport has been scrolled back over. + pub display_offset: usize, + /// Lines the viewport shows at once. + pub screen_lines: usize, + /// Whether the child program owns the alternate screen, which has no history. + pub alt_screen: bool, +} + +impl TerminalScrollState { + /// Returns how many lines of history sit above the viewport. + pub const fn lines_above(&self) -> usize { + self.history_size.saturating_sub(self.display_offset) + } +} + +/// Errors produced while creating or controlling a terminal session. +#[derive(Debug, Error)] +pub enum TerminalSessionError { + #[error("failed to {operation}: {message}")] + Pty { operation: &'static str, message: String }, + #[error("terminal writer lock is poisoned")] + WriterPoisoned, + #[error("terminal output parser lock is poisoned")] + ParserPoisoned, + #[error("in-process terminal program is disconnected")] + ProgramDisconnected, + #[error("failed to write terminal input: {0}")] + Write(#[from] std::io::Error), +} + +/// Output side of an in-process program connected to a VT terminal emulator. +#[derive(Clone)] +pub struct TerminalProgramOutput { + terminal: SharedTerminal, + processor: SharedProcessor, + events: TerminalEventSender, + render_pending: Arc, +} + +impl TerminalProgramOutput { + /// Parses program output as ANSI/VT bytes and schedules a terminal repaint. + pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalSessionError> { + let mut processor = self + .processor + .lock() + .map_err(|_| TerminalSessionError::ParserPoisoned)?; + processor.advance(&mut *self.terminal.lock(), bytes); + send_render_event(&self.events, &self.render_pending); + Ok(()) + } +} + +/// Program-facing endpoint paired with an in-process terminal session. +pub struct TerminalProgram { + input: Mutex>, + output: TerminalProgramOutput, +} + +impl TerminalProgram { + /// Returns a cloneable handle for writing ANSI/VT output to the emulator. + pub fn output(&self) -> TerminalProgramOutput { + self.output.clone() + } + + /// Drains input currently waiting for the in-process program. + pub fn drain_input(&self) -> Result, TerminalSessionError> { + let receiver = self + .input + .lock() + .map_err(|_| TerminalSessionError::ProgramDisconnected)?; + Ok(receiver.try_iter().collect()) + } + + /// Waits for the next input event from the terminal emulator. + pub fn recv(&self) -> Result { + let receiver = self + .input + .lock() + .map_err(|_| TerminalSessionError::ProgramDisconnected)?; + receiver.recv().map_err(|_| TerminalSessionError::ProgramDisconnected) + } +} + +enum TerminalBackend { + NativePty { + master: Box, + killer: Mutex>, + }, + InProcess { + input: mpsc::Sender, + }, +} + +/// Owns one terminal backend and its frontend-independent VT terminal state. +pub struct TerminalSession { + profile: TerminalProfile, + terminal: SharedTerminal, + writer: SharedWriter, + backend: TerminalBackend, + events: Mutex>, + wake: async_channel::Receiver<()>, + render_pending: Arc, +} + +impl TerminalSession { + /// Spawns a discovered shell using its resolved executable and launch arguments. + /// + /// # Parameters + /// + /// * `shell` is a catalog definition selected by the user. + /// * `working_directory` is the initial directory for the child shell. + /// * `size` is the initial character-grid and cell-pixel size. + /// + /// # Returns + /// + /// A live PTY session, or an error if the executable disappeared or spawning failed. + pub fn spawn_shell( + shell: &ShellDefinition, + working_directory: impl AsRef, + size: TerminalSize, + ) -> Result { + if !crate::shell::is_executable(&shell.executable) { + return Err(TerminalSessionError::Pty { + operation: "spawn shell profile", + message: format!("{} is no longer available", shell.executable.display()), + }); + } + let mut command = CommandBuilder::new(&shell.executable); + for argument in &shell.arguments { + command.arg(argument); + } + command.cwd(working_directory.as_ref()); + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + Self::spawn_with_profile(command, TerminalProfile::SystemShell, size) + } + + /// Spawns the operating system's default interactive shell. + /// + /// # Parameters + /// + /// * `working_directory` is the initial directory for the child shell. + /// * `size` is the initial character-grid and cell-pixel size. + /// + /// # Returns + /// + /// A live PTY session and VT terminal state. + pub fn spawn_default_shell( + working_directory: impl AsRef, + size: TerminalSize, + ) -> Result { + let mut command = CommandBuilder::new_default_prog(); + command.cwd(working_directory.as_ref()); + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + Self::spawn_with_profile(command, TerminalProfile::SystemShell, size) + } + + /// Spawns a command attached to a native pseudo-terminal. + /// + /// # Parameters + /// + /// * `command` configures the child program, environment, and working directory. + /// * `size` is the initial character-grid and cell-pixel size. + /// + /// # Returns + /// + /// A live PTY session and VT terminal state. + pub fn spawn(command: CommandBuilder, size: TerminalSize) -> Result { + Self::spawn_with_profile(command, TerminalProfile::SystemShell, size) + } + + /// Spawns a native PTY command and records its terminal profile. + fn spawn_with_profile( + command: CommandBuilder, + profile: TerminalProfile, + size: TerminalSize, + ) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(size.pty_size()) + .map_err(|error| pty_error("open native pseudo-terminal", error))?; + let child = pair + .slave + .spawn_command(command) + .map_err(|error| pty_error("spawn terminal child", error))?; + let killer = child.clone_killer(); + drop(pair.slave); + + let reader = pair + .master + .try_clone_reader() + .map_err(|error| pty_error("clone terminal reader", error))?; + let writer = Arc::new(Mutex::new( + pair + .master + .take_writer() + .map_err(|error| pty_error("take terminal writer", error))?, + )); + let (events, event_receiver) = mpsc::channel(); + let (wake_sender, wake) = async_channel::bounded(1); + let event_sender = TerminalEventSender { + events, + wake: wake_sender, + }; + let render_pending = Arc::new(AtomicBool::new(false)); + let proxy = TerminalEventProxy { + sender: event_sender.clone(), + writer: Arc::clone(&writer), + render_pending: Arc::clone(&render_pending), + }; + let terminal = Arc::new(FairMutex::new(Term::new(Config::default(), &size, proxy))); + + spawn_reader_thread(&terminal, &event_sender, &render_pending, reader)?; + spawn_wait_thread(&event_sender, child)?; + + Ok(Self { + profile, + terminal, + writer, + backend: TerminalBackend::NativePty { + master: pair.master, + killer: Mutex::new(killer), + }, + events: Mutex::new(event_receiver), + wake, + render_pending, + }) + } + + /// Creates a VT terminal connected to an in-process program endpoint. + /// + /// # Parameters + /// + /// * `profile` identifies the program hosted by the terminal surface. + /// * `size` supplies the initial character-grid and cell-pixel dimensions. + /// + /// # Returns + /// + /// A terminal session for the frontend and a program endpoint that receives + /// input bytes and writes ANSI/VT output. + pub fn in_process(profile: TerminalProfile, size: TerminalSize) -> (Self, TerminalProgram) { + let (input_sender, input_receiver) = mpsc::channel(); + let writer: SharedWriter = Arc::new(Mutex::new(Box::new(ProgramInputWriter { + sender: input_sender.clone(), + }))); + let (events, event_receiver) = mpsc::channel(); + let (wake_sender, wake) = async_channel::bounded(1); + let event_sender = TerminalEventSender { + events, + wake: wake_sender, + }; + let render_pending = Arc::new(AtomicBool::new(false)); + let proxy = TerminalEventProxy { + sender: event_sender.clone(), + writer: Arc::clone(&writer), + render_pending: Arc::clone(&render_pending), + }; + let terminal = Arc::new(FairMutex::new(Term::new(Config::default(), &size, proxy))); + let program = TerminalProgram { + input: Mutex::new(input_receiver), + output: TerminalProgramOutput { + terminal: Arc::clone(&terminal), + processor: Arc::new(Mutex::new(ansi::Processor::::new())), + events: event_sender, + render_pending: Arc::clone(&render_pending), + }, + }; + let session = Self { + profile, + terminal, + writer, + backend: TerminalBackend::InProcess { input: input_sender }, + events: Mutex::new(event_receiver), + wake, + render_pending, + }; + (session, program) + } + + /// Returns the profile represented by this terminal backend. + pub const fn profile(&self) -> TerminalProfile { + self.profile + } + + /// Writes encoded keyboard or paste bytes to the terminal child. + pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalSessionError> { + let mut writer = self.writer.lock().map_err(|_| TerminalSessionError::WriterPoisoned)?; + writer.write_all(bytes)?; + writer.flush()?; + Ok(()) + } + + /// Updates both the native PTY and the VT screen dimensions. + pub fn resize(&self, size: TerminalSize) -> Result<(), TerminalSessionError> { + match &self.backend { + TerminalBackend::NativePty { master, .. } => master + .resize(size.pty_size()) + .map_err(|error| pty_error("resize native pseudo-terminal", error))?, + TerminalBackend::InProcess { input } => input + .send(TerminalProgramInput::Resize(size)) + .map_err(|_| TerminalSessionError::ProgramDisconnected)?, + } + self.terminal.lock().resize(size); + Ok(()) + } + + /// Copies the currently visible terminal grid. + pub fn snapshot(&self) -> TerminalSnapshot { + TerminalSnapshot::from_term(&self.terminal.lock()) + } + + /// Moves the visible viewport through the scrollback history. + /// + /// # Parameters + /// + /// * `scroll` is the requested viewport motion. It is clamped to the lines + /// that are actually retained. + /// + /// # Returns + /// + /// This function does not return a value: a viewport motion cannot fail. It + /// does nothing while the child program owns the alternate screen, because + /// that screen keeps no history to move through. + pub fn scroll(&self, scroll: TerminalScroll) { + let mut terminal = self.terminal.lock(); + let scroll = match scroll { + TerminalScroll::Lines(lines) => Scroll::Delta(lines), + TerminalScroll::PageUp => Scroll::PageUp, + TerminalScroll::PageDown => Scroll::PageDown, + TerminalScroll::Top => Scroll::Top, + TerminalScroll::Bottom => Scroll::Bottom, + // Alacritty only exposes relative viewport motion, so an absolute target + // is reached by asking for the difference from where the viewport is now. + // The target is clamped before the cast so a caller asking for a line far + // past the oldest one cannot wrap the difference around into a scroll the + // wrong way. + TerminalScroll::Offset(lines) => { + let offset = terminal.grid().display_offset(); + let target = lines.min(terminal.history_size()); + Scroll::Delta(target as i32 - offset as i32) + } + }; + terminal.scroll_display(scroll); + } + + /// Returns where the viewport currently sits in the scrollback history. + pub fn scroll_state(&self) -> TerminalScrollState { + let terminal = self.terminal.lock(); + TerminalScrollState { + history_size: terminal.history_size(), + display_offset: terminal.grid().display_offset(), + screen_lines: terminal.screen_lines(), + alt_screen: terminal.mode().contains(TermMode::ALT_SCREEN), + } + } + + /// Returns a wake receiver for the session's sole event observer. + /// + /// A wake carries no payload; after receiving one, the observer should call + /// [`Self::drain_events`]. Wakeups are coalesced while one is pending. + pub fn event_wakeups(&self) -> async_channel::Receiver<()> { + self.wake.clone() + } + + /// Drains terminal events accumulated since the previous call. + pub fn drain_events(&self) -> Vec { + // Clear before draining so a concurrent parser update can enqueue the next + // repaint instead of being hidden behind the event currently in the queue. + self.render_pending.store(false, Ordering::Release); + let Ok(receiver) = self.events.lock() else { + return vec![TerminalEvent::Error("terminal event receiver lock is poisoned".into())]; + }; + receiver.try_iter().collect() + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + match &mut self.backend { + TerminalBackend::NativePty { killer, .. } => { + if let Ok(killer) = killer.get_mut() { + let _ = killer.kill(); + } + } + TerminalBackend::InProcess { input } => { + let _ = input.send(TerminalProgramInput::Shutdown); + } + } + } +} + +struct ProgramInputWriter { + sender: mpsc::Sender, +} + +impl Write for ProgramInputWriter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self + .sender + .send(TerminalProgramInput::Bytes(buffer.to_vec())) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "in-process terminal program disconnected", + ) + })?; + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[derive(Clone)] +struct TerminalEventProxy { + sender: TerminalEventSender, + writer: SharedWriter, + render_pending: Arc, +} + +impl EventListener for TerminalEventProxy { + fn send_event(&self, event: AlacrittyEvent) { + match event { + AlacrittyEvent::Wakeup | AlacrittyEvent::MouseCursorDirty => { + send_render_event(&self.sender, &self.render_pending); + } + AlacrittyEvent::Title(title) => { + self.sender.send(TerminalEvent::Title(title)); + } + AlacrittyEvent::Bell => { + self.sender.send(TerminalEvent::Bell); + } + AlacrittyEvent::PtyWrite(text) => match self.writer.lock() { + Ok(mut writer) => { + if let Err(error) = writer.write_all(text.as_bytes()).and_then(|()| writer.flush()) { + self.sender.send(TerminalEvent::Error(error.to_string())); + } + } + Err(_) => { + self + .sender + .send(TerminalEvent::Error("terminal writer lock is poisoned".into())); + } + }, + _ => {} + } + } +} + +/// Starts the blocking PTY reader without holding the terminal lock while waiting for bytes. +fn spawn_reader_thread( + terminal: &SharedTerminal, + sender: &TerminalEventSender, + render_pending: &Arc, + mut reader: Box, +) -> Result<(), TerminalSessionError> { + let terminal = Arc::clone(terminal); + let sender = sender.clone(); + let render_pending = Arc::clone(render_pending); + thread::Builder::new() + .name("chitin-terminal-reader".into()) + .spawn(move || { + let mut processor = ansi::Processor::::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + processor.advance(&mut *terminal.lock(), &buffer[..read]); + send_render_event(&sender, &render_pending); + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => { + sender.send(TerminalEvent::Error(error.to_string())); + break; + } + } + } + }) + .map(|_| ()) + .map_err(TerminalSessionError::Write) +} + +/// Coalesces repeated parser wakeups into at most one queued repaint request. +fn send_render_event(sender: &TerminalEventSender, pending: &AtomicBool) { + if !pending.swap(true, Ordering::AcqRel) { + sender.send(TerminalEvent::Render); + } +} + +/// Starts a waiter that reports the child exit code without blocking a UI executor. +fn spawn_wait_thread( + sender: &TerminalEventSender, + mut child: Box, +) -> Result<(), TerminalSessionError> { + let sender = sender.clone(); + thread::Builder::new() + .name("chitin-terminal-wait".into()) + .spawn(move || match child.wait() { + Ok(status) => { + sender.send(TerminalEvent::Exited(status.exit_code())); + } + Err(error) => { + sender.send(TerminalEvent::Error(error.to_string())); + } + }) + .map(|_| ()) + .map_err(TerminalSessionError::Write) +} + +fn pty_error(operation: &'static str, error: impl std::fmt::Display) -> TerminalSessionError { + TerminalSessionError::Pty { + operation, + message: error.to_string(), + } +} + +#[cfg(test)] +mod event_tests { + use super::*; + + #[test] + fn event_sender_should_coalesce_wakeups_without_discarding_events() { + let (events, receiver) = mpsc::channel(); + let (wake, wakeups) = async_channel::bounded(1); + let sender = TerminalEventSender { events, wake }; + + sender.send(TerminalEvent::Title("first".into())); + sender.send(TerminalEvent::Bell); + + assert_eq!(wakeups.try_recv(), Ok(())); + assert_eq!(wakeups.try_recv(), Err(async_channel::TryRecvError::Empty)); + assert_eq!( + receiver.try_iter().collect::>(), + vec![TerminalEvent::Title("first".into()), TerminalEvent::Bell] + ); + } + + #[test] + fn event_sender_should_wake_again_after_prior_wakeup_is_consumed() { + let (events, _receiver) = mpsc::channel(); + let (wake, wakeups) = async_channel::bounded(1); + let sender = TerminalEventSender { events, wake }; + + sender.send(TerminalEvent::Bell); + assert_eq!(wakeups.try_recv(), Ok(())); + sender.send(TerminalEvent::Bell); + assert_eq!(wakeups.try_recv(), Ok(())); + } + + #[test] + fn in_process_session_should_sleep_until_program_output_arrives() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(40, 4, 8, 16)); + let wakeups = session.event_wakeups(); + assert_eq!(wakeups.try_recv(), Err(async_channel::TryRecvError::Empty)); + + assert!(program.output().write(b"output").is_ok()); + assert_eq!(wakeups.try_recv(), Ok(())); + assert!(session.drain_events().contains(&TerminalEvent::Render)); + assert_eq!(wakeups.try_recv(), Err(async_channel::TryRecvError::Empty)); + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::{path::PathBuf, thread, time::Duration}; + + use super::*; + + #[test] + fn session_should_parse_output_from_a_native_pty() { + let mut command = CommandBuilder::new("sh"); + command.args(["-c", "printf '\\033[32mnative-pty\\033[0m'"]); + let Ok(session) = TerminalSession::spawn(command, TerminalSize::new(40, 4, 8, 16)) else { + panic!("native PTY should be available during a Unix test"); + }; + + let mut visible_text = String::new(); + for _ in 0..100 { + visible_text = session.snapshot().cells.iter().map(|cell| cell.character).collect(); + if visible_text.contains("native-pty") { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + assert!(visible_text.contains("native-pty")); + assert_eq!( + session.snapshot().cell(0, 0).map(|cell| cell.foreground), + Some(crate::TerminalColor::Named(crate::TerminalNamedColor::Green)), + ); + } + + #[test] + fn native_pty_should_report_the_child_exit_code() { + let mut command = CommandBuilder::new("sh"); + command.args(["-c", "exit 3"]); + let Ok(session) = TerminalSession::spawn(command, TerminalSize::new(40, 4, 8, 16)) else { + panic!("native PTY should be available during a Unix test"); + }; + + let mut exit_code = None; + for _ in 0..200 { + exit_code = session.drain_events().into_iter().find_map(|event| match event { + TerminalEvent::Exited(code) => Some(code), + _ => None, + }); + if exit_code.is_some() { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + assert_eq!(exit_code, Some(3)); + } + + #[cfg(target_os = "linux")] + #[test] + fn dropping_a_session_should_terminate_the_pty_child() { + let pid_file = std::env::temp_dir().join("chitin-terminal-dropped-child.pid"); + let _ = std::fs::remove_file(&pid_file); + let mut command = CommandBuilder::new("sh"); + command.args(["-c", &format!("echo $$ > {}; exec sleep 300", pid_file.display())]); + let Ok(session) = TerminalSession::spawn(command, TerminalSize::new(40, 4, 8, 16)) else { + panic!("native PTY should be available during a Unix test"); + }; + let reported = (0..200) + .find_map(|_| { + thread::sleep(Duration::from_millis(10)); + std::fs::read_to_string(&pid_file).ok() + }) + .and_then(|pid| pid.trim().parse::().ok()); + let Some(child_pid) = reported else { + panic!("child should report its process id"); + }; + assert!(PathBuf::from(format!("/proc/{child_pid}")).exists()); + + drop(session); + + let exited = (0..500).any(|_| { + thread::sleep(Duration::from_millis(10)); + !PathBuf::from(format!("/proc/{child_pid}")).exists() + }); + let _ = std::fs::remove_file(&pid_file); + assert!(exited, "child {child_pid} outlived the session that owned it"); + } + + #[test] + fn in_process_program_should_receive_terminal_input_bytes() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(40, 4, 8, 16)); + + let write_result = session.write(b"help\r"); + let input = program.recv(); + + assert!(write_result.is_ok()); + assert_eq!(input.ok(), Some(TerminalProgramInput::Bytes(b"help\r".to_vec()))); + } + + #[test] + fn in_process_program_output_should_update_the_shared_vt_snapshot() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(40, 4, 8, 16)); + + let write_result = program.output().write(b"\x1b[32mbuiltin-shell\x1b[0m"); + let visible_text = session + .snapshot() + .cells + .iter() + .map(|cell| cell.character) + .collect::(); + + assert!(write_result.is_ok()); + assert!(visible_text.contains("builtin-shell")); + } + + /// Writes bytes out of an in-process program, failing the test if it refuses them. + fn feed(program: &TerminalProgram, bytes: &[u8]) { + assert!( + program.output().write(bytes).is_ok(), + "an in-process program should accept output" + ); + } + + /// Runs a terminal whose scrollback holds `lines` numbered lines. + fn session_with_scrollback(lines: usize) -> TerminalSession { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(20, 4, 8, 16)); + let written = (1..=lines).map(|line| format!("line-{line}\r\n")).collect::(); + feed(&program, written.as_bytes()); + session + } + + /// Returns the trimmed text of one visible row. + fn row_text(snapshot: &TerminalSnapshot, row: usize) -> String { + (0..snapshot.columns) + .filter_map(|column| snapshot.cell(row, column)) + .map(|cell| cell.character) + .collect::() + .trim_end() + .to_owned() + } + + #[test] + fn scroll_should_move_the_viewport_back_through_history() { + let session = session_with_scrollback(10); + + assert_eq!(session.scroll_state().display_offset, 0); + session.scroll(TerminalScroll::Lines(2)); + + assert_eq!(session.scroll_state().display_offset, 2); + assert_eq!(session.snapshot().display_offset, 2); + } + + #[test] + fn a_scrolled_snapshot_should_show_older_rows_than_the_live_edge() { + let session = session_with_scrollback(10); + let live = session.snapshot(); + let history_size = live.history_size; + + session.scroll(TerminalScroll::Lines(2)); + let scrolled = session.snapshot(); + + assert!( + history_size >= 2, + "the scrollback should hold the two lines being scrolled over" + ); + assert_ne!(row_text(&scrolled, 0), row_text(&live, 0)); + // The two views overlap: what the scrolled viewport shows at row `row` is + // what the live viewport shows at row `row - 2`, because scrolled content is + // older content. + for row in 2..scrolled.rows { + assert_eq!(row_text(&scrolled, row), row_text(&live, row - 2)); + } + assert_eq!(scrolled.history_size, history_size); + } + + #[test] + fn scrolling_forward_past_the_live_edge_should_stop_there() { + let session = session_with_scrollback(10); + + session.scroll(TerminalScroll::Lines(-5)); + + assert_eq!(session.scroll_state().display_offset, 0); + } + + #[test] + fn scrolling_back_past_the_oldest_line_should_stop_there() { + let session = session_with_scrollback(10); + let history_size = session.scroll_state().history_size; + + session.scroll(TerminalScroll::Lines(history_size as i32 + 50)); + + assert_eq!(session.scroll_state().display_offset, history_size); + } + + #[test] + fn the_top_motion_should_reach_the_oldest_line_and_the_bottom_motion_should_return() { + let session = session_with_scrollback(10); + let history_size = session.scroll_state().history_size; + assert!(history_size > 0, "the fixture should have pushed lines into history"); + + session.scroll(TerminalScroll::Top); + assert_eq!(session.scroll_state().display_offset, history_size); + assert_eq!(session.scroll_state().lines_above(), 0); + + session.scroll(TerminalScroll::Bottom); + assert_eq!(session.scroll_state().display_offset, 0); + assert_eq!(session.scroll_state().lines_above(), history_size); + } + + #[test] + fn page_scroll_should_move_the_viewport_by_one_screenful() { + let session = session_with_scrollback(40); + let screen_lines = session.scroll_state().screen_lines; + + session.scroll(TerminalScroll::PageUp); + assert_eq!(session.scroll_state().display_offset, screen_lines); + + session.scroll(TerminalScroll::PageDown); + assert_eq!(session.scroll_state().display_offset, 0); + } + + #[test] + fn an_offset_should_set_the_viewport_absolutely() { + let session = session_with_scrollback(40); + let history_size = session.scroll_state().history_size; + + session.scroll(TerminalScroll::Offset(3)); + assert_eq!(session.scroll_state().display_offset, 3); + assert_eq!(session.snapshot().display_offset, 3); + + // A target past the oldest retained line lands on it rather than wrapping. + session.scroll(TerminalScroll::Offset(usize::MAX)); + assert_eq!(session.scroll_state().display_offset, history_size); + } + + #[test] + fn a_snapshot_should_not_report_a_cursor_while_scrolled() { + let session = session_with_scrollback(10); + + assert!(session.snapshot().cursor.is_some()); + session.scroll(TerminalScroll::Lines(2)); + assert_eq!(session.snapshot().cursor, None); + + session.scroll(TerminalScroll::Bottom); + assert!(session.snapshot().cursor.is_some()); + } + + #[test] + fn scrolling_should_do_nothing_on_the_alternate_screen() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(20, 4, 8, 16)); + let written = (1..=10).map(|line| format!("line-{line}\r\n")).collect::(); + feed(&program, written.as_bytes()); + feed(&program, b"\x1b[?1049h"); + + let alternate = session.scroll_state(); + assert!(alternate.alt_screen, "the fixture should have switched screens"); + assert_eq!(alternate.history_size, 0); + + session.scroll(TerminalScroll::Top); + assert_eq!(session.scroll_state().display_offset, 0); + + feed(&program, b"\x1b[?1049l"); + assert!(!session.scroll_state().alt_screen); + assert!( + session.scroll_state().history_size > 0, + "the primary scrollback should return" + ); + } + + #[test] + fn in_process_program_should_receive_terminal_resize() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(40, 4, 8, 16)); + let expected = TerminalSize::new(80, 24, 9, 18); + + let resize_result = session.resize(expected); + let input = program.recv(); + + assert!(resize_result.is_ok()); + assert_eq!(input.ok(), Some(TerminalProgramInput::Resize(expected))); + } +} diff --git a/crates/chitin-terminal/src/shell.rs b/crates/chitin-terminal/src/shell.rs new file mode 100644 index 00000000..c6fdc805 --- /dev/null +++ b/crates/chitin-terminal/src/shell.rs @@ -0,0 +1,269 @@ +//! Platform-specific discovery of shells that can back native PTY sessions. + +use std::{ + env, + ffi::OsString, + fs, + path::{Path, PathBuf}, +}; + +/// A discovered shell that can be launched in a native pseudo-terminal. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellDefinition { + /// Stable identity used by terminal profile selectors. + pub id: String, + /// Name shown to the user. + pub label: String, + /// Resolved executable, independent of later `PATH` changes. + pub executable: PathBuf, + /// Arguments passed when the shell starts. + pub arguments: Vec, +} + +impl ShellDefinition { + fn new(id: &str, label: &str, executable: PathBuf, arguments: &[&str]) -> Self { + Self { + id: id.into(), + label: label.into(), + executable, + arguments: arguments.iter().map(OsString::from).collect(), + } + } +} + +/// Shells available to the current desktop process. +#[derive(Clone, Debug, Default)] +pub struct ShellCatalog { + definitions: Vec, +} + +impl ShellCatalog { + /// Discovers launchable system shells using the current platform's conventions. + /// + /// # Returns + /// + /// A snapshot of available shells; applications should rediscover after changes to + /// installed shells or the process environment. The Chitin built-in shell is not + /// included because it does not require an executable or PTY. + pub fn discover() -> Self { + let mut catalog = Self::default(); + platform::discover(&mut catalog); + catalog + } + + /// Returns the discovered launchable definitions in display order. + pub fn available(&self) -> &[ShellDefinition] { + &self.definitions + } + + /// Looks up a discovered shell by its stable selector identity. + pub fn get(&self, id: &str) -> Option<&ShellDefinition> { + self.definitions.iter().find(|definition| definition.id == id) + } + + fn add(&mut self, id: &str, label: &str, executable: PathBuf, arguments: &[&str]) { + if self.get(id).is_none() && is_executable(&executable) { + self + .definitions + .push(ShellDefinition::new(id, label, executable, arguments)); + } + } + + #[cfg(windows)] + fn add_named(&mut self, id: &str, label: &str, name: &str, arguments: &[&str]) { + if let Some(executable) = find_on_path(name) { + self.add(id, label, executable, arguments); + } + } +} + +fn find_on_path(name: &str) -> Option { + let paths = env::var_os("PATH")?; + for directory in env::split_paths(&paths) { + for candidate in executable_names(name) { + let path = directory.join(candidate); + if is_executable(&path) { + return Some(path); + } + } + } + None +} + +#[cfg(windows)] +fn executable_names(name: &str) -> Vec { + let path = Path::new(name); + if path.extension().is_some() { + return vec![OsString::from(name)]; + } + let extensions = env::var_os("PATHEXT").unwrap_or_else(|| OsString::from(".COM;.EXE;.BAT;.CMD")); + extensions + .to_string_lossy() + .split(';') + .filter(|extension| !extension.is_empty()) + .map(|extension| OsString::from(format!("{name}{extension}"))) + .collect() +} + +#[cfg(not(windows))] +fn executable_names(name: &str) -> Vec { + vec![OsString::from(name)] +} + +pub(crate) fn is_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(unix)] +mod platform { + use super::*; + + pub(super) fn discover(catalog: &mut ShellCatalog) { + if let Some(default) = env::var_os("SHELL").map(PathBuf::from) + && is_executable(&default) + { + catalog.add("system-shell", "Default shell", default, &[]); + } + + // /etc/shells records login shells that might live outside the GUI process's PATH. + let registered = fs::read_to_string("/etc/shells").unwrap_or_default(); + for (id, label, name) in [ + ("bash", "Bash", "bash"), + ("zsh", "Zsh", "zsh"), + ("fish", "Fish", "fish"), + ("nushell", "Nushell", "nu"), + ("pwsh", "PowerShell 7", "pwsh"), + ] { + let registered_path = registered + .lines() + .map(str::trim) + .filter(|line| line.starts_with('/') && !line.starts_with('#')) + .map(PathBuf::from) + .find(|path| path.file_name().is_some_and(|file| file == name) && is_executable(path)); + if let Some(path) = registered_path.or_else(|| find_on_path(name)) { + catalog.add(id, label, path, &[]); + } + } + if catalog.get("system-shell").is_none() + && let Some(path) = find_on_path("sh") + { + catalog.add("system-shell", "Default shell", path, &[]); + } + } +} + +#[cfg(windows)] +mod platform { + use super::*; + + pub(super) fn discover(catalog: &mut ShellCatalog) { + let system32 = env::var_os("SystemRoot") + .map(PathBuf::from) + .map(|root| root.join("System32")); + if let Some(path) = env::var_os("ComSpec").map(PathBuf::from) + && is_executable(&path) + { + catalog.add("system-shell", "Default shell", path, &[]); + } + if catalog.get("system-shell").is_none() { + catalog.add_named("system-shell", "Default shell", "cmd", &[]); + } + if catalog.get("system-shell").is_none() + && let Some(system32) = &system32 + { + catalog.add("system-shell", "Default shell", system32.join("cmd.exe"), &[]); + } + catalog.add_named("cmd", "Command Prompt", "cmd", &[]); + if let Some(system32) = &system32 { + catalog.add("cmd", "Command Prompt", system32.join("cmd.exe"), &[]); + } + catalog.add_named("powershell", "Windows PowerShell", "powershell", &[]); + if let Some(system32) = &system32 { + catalog.add( + "powershell", + "Windows PowerShell", + system32.join("WindowsPowerShell/v1.0/powershell.exe"), + &[], + ); + } + catalog.add_named("pwsh", "PowerShell 7", "pwsh", &[]); + for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] { + if let Some(root) = env::var_os(variable).map(PathBuf::from) { + catalog.add("pwsh", "PowerShell 7", root.join("PowerShell/7/pwsh.exe"), &[]); + } + } + catalog.add_named("nushell", "Nushell", "nu", &[]); + catalog.add_named("bash", "Bash", "bash", &[]); + catalog.add_named("fish", "Fish", "fish", &[]); + + // Git for Windows places git.exe on PATH but its interactive bash.exe under bin/. + if let Some(git) = find_on_path("git") + && let Some(root) = git.parent().and_then(Path::parent) + { + catalog.add("git-bash", "Git Bash", root.join("bin/bash.exe"), &["--login", "-i"]); + } + for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] { + if let Some(root) = env::var_os(variable).map(PathBuf::from) { + catalog.add( + "git-bash", + "Git Bash", + root.join("Git/bin/bash.exe"), + &["--login", "-i"], + ); + } + } + } +} + +#[cfg(not(any(unix, windows)))] +mod platform { + use super::*; + + pub(super) fn discover(catalog: &mut ShellCatalog) { + let _ = catalog; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_omits_non_executable_shells() { + let mut catalog = ShellCatalog::default(); + catalog.add("missing", "Missing", PathBuf::from("/not/a/chitin/shell"), &[]); + assert!(catalog.available().is_empty()); + } + + #[test] + fn catalog_keeps_first_definition_for_a_stable_id() -> std::io::Result<()> { + let mut catalog = ShellCatalog::default(); + let executable = env::current_exe()?; + catalog.add("test", "First", executable.clone(), &[]); + catalog.add("test", "Second", executable, &[]); + assert_eq!(catalog.get("test").map(|shell| shell.label.as_str()), Some("First")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn catalog_discovers_pwsh_when_installed() { + if find_on_path("pwsh").is_some() { + assert!(ShellCatalog::discover().get("pwsh").is_some()); + } + } +} diff --git a/crates/chitin-terminal/src/size.rs b/crates/chitin-terminal/src/size.rs new file mode 100644 index 00000000..8c54a502 --- /dev/null +++ b/crates/chitin-terminal/src/size.rs @@ -0,0 +1,59 @@ +use alacritty_terminal::grid::Dimensions; + +/// Character-grid and pixel dimensions reported to a terminal process. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TerminalSize { + /// Number of visible character columns. + pub columns: usize, + /// Number of visible character rows. + pub rows: usize, + /// Width of one terminal cell in device pixels. + pub cell_width: u16, + /// Height of one terminal cell in device pixels. + pub cell_height: u16, +} + +impl TerminalSize { + /// Creates a terminal size with explicit grid and cell dimensions. + pub const fn new(columns: usize, rows: usize, cell_width: u16, cell_height: u16) -> Self { + Self { + columns, + rows, + cell_width, + cell_height, + } + } + + /// Converts this size into the PTY representation. + pub(crate) fn pty_size(self) -> portable_pty::PtySize { + let rows = self.rows.min(u16::MAX as usize) as u16; + let columns = self.columns.min(u16::MAX as usize) as u16; + + portable_pty::PtySize { + rows, + cols: columns, + pixel_width: self.cell_width.saturating_mul(columns), + pixel_height: self.cell_height.saturating_mul(rows), + } + } +} + +impl Dimensions for TerminalSize { + fn total_lines(&self) -> usize { + self.rows + } + + fn screen_lines(&self) -> usize { + self.rows + } + + fn columns(&self) -> usize { + self.columns + } +} + +impl Default for TerminalSize { + fn default() -> Self { + Self::new(80, 24, 8, 16) + } +} diff --git a/crates/chitin-terminal/src/snapshot.rs b/crates/chitin-terminal/src/snapshot.rs new file mode 100644 index 00000000..a389400a --- /dev/null +++ b/crates/chitin-terminal/src/snapshot.rs @@ -0,0 +1,330 @@ +use alacritty_terminal::{ + Term, + event::EventListener, + grid::Dimensions, + term::cell::Flags, + vte::ansi::{Color, NamedColor}, +}; + +/// A frontend-neutral copy of the visible terminal grid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalSnapshot { + /// Number of visible columns. + pub columns: usize, + /// Number of visible rows. + pub rows: usize, + /// Visible cells stored in row-major order. + pub cells: Vec, + /// Visible cursor location, when the terminal requests a cursor. + /// + /// A cursor the terminal has hidden, or one that is not inside the visible + /// viewport because the viewport has been scrolled back, is reported absent. + pub cursor: Option<(usize, usize)>, + /// Lines of history the viewport has been scrolled back over, where zero is + /// the live edge. + pub display_offset: usize, + /// Lines of scrollback retained behind the viewport. + pub history_size: usize, +} + +impl TerminalSnapshot { + /// Copies the visible grid from an Alacritty terminal state machine. + pub(crate) fn from_term(term: &Term) -> Self { + let content = term.renderable_content(); + let columns = term.columns(); + let rows = term.screen_lines(); + // The cursor point is an absolute grid coordinate, so once the viewport has + // been scrolled back it names a line below the visible rows. Clamping it + // into range would paint it onto whichever row happened to be last. + let cursor = (content.display_offset == 0 + && !matches!(content.cursor.shape, alacritty_terminal::vte::ansi::CursorShape::Hidden)) + .then(|| { + ( + content.cursor.point.line.0.max(0) as usize, + content.cursor.point.column.0, + ) + }); + let mut cells = vec![TerminalCell::default(); rows.saturating_mul(columns)]; + + for indexed in content.display_iter { + // The iterator reports absolute grid lines and starts one line above the + // viewport, so a line is negative whenever the viewport has been scrolled + // back. Adding the offset turns an absolute line into the row it occupies + // on screen, and the one line above the viewport falls out as row -1. + let row = indexed.point.line.0 + content.display_offset as i32; + if row < 0 { + continue; + } + let row = row as usize; + let column = indexed.point.column.0; + let Some(cell) = cells.get_mut(row.saturating_mul(columns).saturating_add(column)) else { + continue; + }; + + *cell = TerminalCell { + character: indexed.cell.c, + combining_characters: indexed.cell.zerowidth().unwrap_or_default().to_vec(), + foreground: indexed.cell.fg.into(), + background: indexed.cell.bg.into(), + attributes: TerminalCellAttributes { + bold: indexed.cell.flags.intersects(Flags::BOLD | Flags::DIM_BOLD), + dim: indexed.cell.flags.intersects(Flags::DIM | Flags::DIM_BOLD), + italic: indexed.cell.flags.intersects(Flags::ITALIC | Flags::BOLD_ITALIC), + underline: indexed.cell.flags.intersects(Flags::ALL_UNDERLINES), + inverse: indexed.cell.flags.contains(Flags::INVERSE), + hidden: indexed.cell.flags.contains(Flags::HIDDEN), + wide: indexed.cell.flags.contains(Flags::WIDE_CHAR), + wide_spacer: indexed + .cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER), + }, + }; + } + + Self { + columns, + rows, + cells, + cursor, + display_offset: content.display_offset, + history_size: term.history_size(), + } + } + + /// Returns the cell at a visible grid coordinate. + pub fn cell(&self, row: usize, column: usize) -> Option<&TerminalCell> { + if row >= self.rows || column >= self.columns { + return None; + } + + self.cells.get(row * self.columns + column) + } +} + +/// Character and visual attributes for one terminal grid cell. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalCell { + /// Unicode scalar displayed in the cell. + pub character: char, + /// Zero-width scalars that complete the cell's displayed grapheme. + pub combining_characters: Vec, + /// Requested foreground color. + pub foreground: TerminalColor, + /// Requested background color. + pub background: TerminalColor, + /// Additional text attributes. + pub attributes: TerminalCellAttributes, +} + +impl Default for TerminalCell { + fn default() -> Self { + Self { + character: ' ', + combining_characters: Vec::new(), + foreground: TerminalColor::Named(TerminalNamedColor::Foreground), + background: TerminalColor::Named(TerminalNamedColor::Background), + attributes: TerminalCellAttributes::default(), + } + } +} + +/// Font and compositing attributes attached to one terminal cell. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TerminalCellAttributes { + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub inverse: bool, + pub hidden: bool, + /// Whether the cell leads a glyph that occupies this column and the next one. + pub wide: bool, + /// Whether the cell continues a wide glyph instead of holding one of its own. + pub wide_spacer: bool, +} + +/// A terminal color before it is resolved against the UI palette. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalColor { + Named(TerminalNamedColor), + Indexed(u8), + Rgb { red: u8, green: u8, blue: u8 }, +} + +impl From for TerminalColor { + fn from(value: Color) -> Self { + match value { + Color::Named(color) => Self::Named(color.into()), + Color::Indexed(index) => Self::Indexed(index), + Color::Spec(color) => Self::Rgb { + red: color.r, + green: color.g, + blue: color.b, + }, + } + } +} + +/// Semantic and ANSI named colors understood by the VT state machine. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalNamedColor { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + BrightBlack, + BrightRed, + BrightGreen, + BrightYellow, + BrightBlue, + BrightMagenta, + BrightCyan, + BrightWhite, + Foreground, + Background, + Cursor, + DimBlack, + DimRed, + DimGreen, + DimYellow, + DimBlue, + DimMagenta, + DimCyan, + DimWhite, + BrightForeground, + DimForeground, +} + +impl From for TerminalNamedColor { + fn from(value: NamedColor) -> Self { + match value { + NamedColor::Black => Self::Black, + NamedColor::Red => Self::Red, + NamedColor::Green => Self::Green, + NamedColor::Yellow => Self::Yellow, + NamedColor::Blue => Self::Blue, + NamedColor::Magenta => Self::Magenta, + NamedColor::Cyan => Self::Cyan, + NamedColor::White => Self::White, + NamedColor::BrightBlack => Self::BrightBlack, + NamedColor::BrightRed => Self::BrightRed, + NamedColor::BrightGreen => Self::BrightGreen, + NamedColor::BrightYellow => Self::BrightYellow, + NamedColor::BrightBlue => Self::BrightBlue, + NamedColor::BrightMagenta => Self::BrightMagenta, + NamedColor::BrightCyan => Self::BrightCyan, + NamedColor::BrightWhite => Self::BrightWhite, + NamedColor::Foreground => Self::Foreground, + NamedColor::Background => Self::Background, + NamedColor::Cursor => Self::Cursor, + NamedColor::DimBlack => Self::DimBlack, + NamedColor::DimRed => Self::DimRed, + NamedColor::DimGreen => Self::DimGreen, + NamedColor::DimYellow => Self::DimYellow, + NamedColor::DimBlue => Self::DimBlue, + NamedColor::DimMagenta => Self::DimMagenta, + NamedColor::DimCyan => Self::DimCyan, + NamedColor::DimWhite => Self::DimWhite, + NamedColor::BrightForeground => Self::BrightForeground, + NamedColor::DimForeground => Self::DimForeground, + } + } +} + +#[cfg(test)] +mod tests { + use alacritty_terminal::{ + Term, + event::VoidListener, + term::Config, + vte::ansi::{Processor, StdSyncHandler}, + }; + + use super::*; + use crate::TerminalSize; + + #[test] + fn snapshot_should_preserve_visible_text_and_sgr_attributes() { + let size = TerminalSize::new(12, 3, 8, 16); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut processor = Processor::::new(); + + processor.advance(&mut term, b"plain \x1b[1;31mbold\x1b[0m"); + let snapshot = TerminalSnapshot::from_term(&term); + + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.character), Some('p')); + assert_eq!(snapshot.cell(0, 6).map(|cell| cell.character), Some('b')); + assert_eq!(snapshot.cell(0, 6).map(|cell| cell.attributes.bold), Some(true)); + assert_eq!( + snapshot.cell(0, 6).map(|cell| cell.foreground), + Some(TerminalColor::Named(TerminalNamedColor::Red)), + ); + } + + #[test] + fn snapshot_should_preserve_emoji_variation_selectors() { + let size = TerminalSize::new(12, 3, 8, 16); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut processor = Processor::::new(); + + processor.advance(&mut term, "☀️".as_bytes()); + let snapshot = TerminalSnapshot::from_term(&term); + + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.character), Some('☀')); + assert_eq!( + snapshot.cell(0, 0).map(|cell| cell.combining_characters.as_slice()), + Some(&['\u{fe0f}'][..]) + ); + } + + #[test] + fn wide_character_should_own_two_columns() { + let size = TerminalSize::new(12, 3, 8, 16); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut processor = Processor::::new(); + + processor.advance(&mut term, "中文".as_bytes()); + let snapshot = TerminalSnapshot::from_term(&term); + + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.character), Some('中')); + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.attributes.wide), Some(true)); + assert_eq!(snapshot.cell(0, 1).map(|cell| cell.attributes.wide_spacer), Some(true)); + assert_eq!(snapshot.cell(0, 2).map(|cell| cell.character), Some('文')); + assert_eq!(snapshot.cell(0, 2).map(|cell| cell.attributes.wide), Some(true)); + assert_eq!(snapshot.cell(0, 3).map(|cell| cell.attributes.wide_spacer), Some(true)); + } + + #[test] + fn narrow_character_should_own_one_column() { + let size = TerminalSize::new(12, 3, 8, 16); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut processor = Processor::::new(); + + processor.advance(&mut term, b"M"); + let snapshot = TerminalSnapshot::from_term(&term); + + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.attributes.wide), Some(false)); + assert_eq!(snapshot.cell(0, 1).map(|cell| cell.attributes.wide_spacer), Some(false)); + } + + #[test] + fn cell_should_reject_coordinates_outside_the_visible_grid() { + let snapshot = TerminalSnapshot { + columns: 2, + rows: 1, + cells: vec![TerminalCell::default(); 2], + cursor: None, + display_offset: 0, + history_size: 0, + }; + + assert!(snapshot.cell(0, 1).is_some()); + assert!(snapshot.cell(1, 0).is_none()); + assert!(snapshot.cell(0, 2).is_none()); + } +} diff --git a/crates/chitin-ui/Cargo.toml b/crates/chitin-ui/Cargo.toml index 46c800f9..14474e27 100644 --- a/crates/chitin-ui/Cargo.toml +++ b/crates/chitin-ui/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true description = "Reusable GPUI components for chitin desktop application" [dependencies] +chitin-terminal = { path = "../chitin-terminal" } gpui = { package = "gpui-ce", git = "https://github.com/chitin-dev/gpui-wgpu", rev = "d01b063d0dce9df2be24a53f59201e010712fc01" } log = "0.4.33" diff --git a/crates/chitin-ui/src/composite/bottom_dock/mod.rs b/crates/chitin-ui/src/composite/bottom_dock/mod.rs new file mode 100644 index 00000000..5dcd2821 --- /dev/null +++ b/crates/chitin-ui/src/composite/bottom_dock/mod.rs @@ -0,0 +1,15 @@ +//! Resizable bottom workbench overlay dock. +//! +//! The dock is independent from document tabs and split panels. Applications +//! select an active dock item, then supply that item's content to [`BottomDock`]. +//! The rendered dock is absolutely positioned over its containing workbench +//! layer, so it does not participate in document-area size allocation. + +mod model; +mod render; + +pub use model::{ + BottomDockItemId, BottomDockState, DEFAULT_BOTTOM_DOCK_HEIGHT, DEFAULT_BOTTOM_DOCK_MIN_HEIGHT, + DEFAULT_CENTER_AREA_MIN_HEIGHT, +}; +pub use render::{BottomDock, BottomDockResizeConfig}; diff --git a/crates/chitin-ui/src/composite/bottom_dock/model.rs b/crates/chitin-ui/src/composite/bottom_dock/model.rs new file mode 100644 index 00000000..f81ebaab --- /dev/null +++ b/crates/chitin-ui/src/composite/bottom_dock/model.rs @@ -0,0 +1,201 @@ +//! Active-item and resize state for the bottom dock. + +use gpui::{Pixels, SharedString, px}; + +use crate::primitive::resize::ResizeGesture; + +/// Default bottom-dock height. +pub const DEFAULT_BOTTOM_DOCK_HEIGHT: Pixels = px(260.0); +/// Default minimum height retained by an open bottom dock. +pub const DEFAULT_BOTTOM_DOCK_MIN_HEIGHT: Pixels = px(120.0); +/// Default minimum height reserved for the workbench center area. +pub const DEFAULT_CENTER_AREA_MIN_HEIGHT: Pixels = px(160.0); + +/// Stable semantic identity of one bottom-dock tool. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct BottomDockItemId(SharedString); + +impl BottomDockItemId { + /// Creates a dock-item identity from an application-defined stable string. + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + /// Returns the stable string representation. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Active tool and geometry state for a reusable bottom workbench dock. +#[derive(Clone, Debug)] +pub struct BottomDockState { + active_item: Option, + height: Pixels, + min_height: Pixels, + min_center_height: Pixels, + resize_drag: Option>, +} + +impl BottomDockState { + /// Creates a closed dock with the default height constraints. + pub fn new() -> Self { + Self { + active_item: None, + height: DEFAULT_BOTTOM_DOCK_HEIGHT, + min_height: DEFAULT_BOTTOM_DOCK_MIN_HEIGHT, + min_center_height: DEFAULT_CENTER_AREA_MIN_HEIGHT, + resize_drag: None, + } + } + + /// Returns the active dock item, or `None` while the dock is closed. + pub fn active_item(&self) -> Option<&BottomDockItemId> { + self.active_item.as_ref() + } + + /// Returns whether any dock item is visible. + pub const fn is_open(&self) -> bool { + self.active_item.is_some() + } + + /// Returns whether the identified item is currently visible. + pub fn is_active(&self, item_id: &str) -> bool { + self + .active_item + .as_ref() + .is_some_and(|active| active.as_str() == item_id) + } + + /// Shows the identified item, replacing the previously active dock tool. + pub fn show(&mut self, item_id: impl Into) { + self.active_item = Some(BottomDockItemId::new(item_id)); + } + + /// Toggles one item and returns whether it is visible afterward. + /// + /// An inactive item replaces the current dock content instead of closing the + /// dock. Toggling the already-active item closes the dock. + /// + /// # Parameters + /// + /// * `item_id` is the stable identity of the tool being toggled. + /// + /// # Returns + /// + /// `true` when the requested item is visible afterward; otherwise `false`. + pub fn toggle(&mut self, item_id: impl Into) -> bool { + let item_id = BottomDockItemId::new(item_id); + if self.active_item.as_ref() == Some(&item_id) { + self.close(); + false + } else { + self.active_item = Some(item_id); + true + } + } + + /// Closes the dock and stops any active resize gesture. + pub fn close(&mut self) { + self.active_item = None; + self.resize_drag = None; + } + + /// Returns the current dock height. + pub const fn height(&self) -> Pixels { + self.height + } + + /// Starts resizing from the dock's top edge. + pub fn start_resize(&mut self, start_y: Pixels) { + self.resize_drag = Some(ResizeGesture::new((), start_y, self.height)); + } + + /// Updates dock height while preserving minimum center-area space. + /// + /// # Parameters + /// + /// * `current_y` is the latest vertical pointer position. + /// * `available_height` is the workbench height available to the center area + /// and bottom dock together. + /// + /// # Returns + /// + /// `true` when an active resize gesture updated the dock; otherwise `false`. + pub fn drag_resize(&mut self, current_y: Pixels, available_height: Pixels) -> bool { + let Some(resize_drag) = self.resize_drag else { + return false; + }; + let maximum_height = + px((f32::from(available_height) - f32::from(self.min_center_height)).max(f32::from(self.min_height))); + let desired_height = f32::from(resize_drag.start_value()) - resize_drag.delta(current_y); + self.height = px(desired_height.clamp(f32::from(self.min_height), f32::from(maximum_height))); + true + } + + /// Stops the active dock resize gesture. + pub fn stop_resize(&mut self) -> bool { + self.resize_drag.take().is_some() + } + + /// Returns whether the dock top edge is being dragged. + pub const fn is_resizing(&self) -> bool { + self.resize_drag.is_some() + } +} + +impl Default for BottomDockState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggling_active_item_should_close_dock() { + let mut state = BottomDockState::new(); + state.show("terminal"); + + assert!(!state.toggle("terminal")); + assert!(!state.is_open()); + } + + #[test] + fn toggling_inactive_item_should_replace_content() { + let mut state = BottomDockState::new(); + state.show("terminal"); + + assert!(state.toggle("tasks")); + assert!(state.is_active("tasks")); + } + + #[test] + fn drag_resize_should_increase_height_when_pointer_moves_up() { + let mut state = BottomDockState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(350.0), px(800.0))); + assert_eq!(state.height(), px(310.0)); + } + + #[test] + fn drag_resize_should_preserve_minimum_center_height() { + let mut state = BottomDockState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(0.0), px(600.0))); + assert_eq!(state.height(), px(440.0)); + } + + #[test] + fn drag_resize_should_preserve_minimum_dock_height() { + let mut state = BottomDockState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(800.0), px(600.0))); + assert_eq!(state.height(), DEFAULT_BOTTOM_DOCK_MIN_HEIGHT); + } +} diff --git a/crates/chitin-ui/src/composite/bottom_dock/render.rs b/crates/chitin-ui/src/composite/bottom_dock/render.rs new file mode 100644 index 00000000..747be268 --- /dev/null +++ b/crates/chitin-ui/src/composite/bottom_dock/render.rs @@ -0,0 +1,205 @@ +//! Generic bottom-dock layout and chrome. + +use std::rc::Rc; + +use gpui::{ + AnyElement, App, CursorStyle, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, + RenderOnce, SharedString, Window, div, prelude::*, px, +}; + +use crate::{ + primitive::{ + button::{Button, ButtonSize, ButtonState, ButtonStyle, ButtonVariant}, + icon::Icon, + }, + themes::{UIThemes, builtins}, +}; + +type BottomDockResizeStartHandler = dyn Fn(Pixels, &mut Window, &mut App); +type BottomDockResizeHandler = dyn Fn(Pixels, &mut Window, &mut App); +type BottomDockResizeEndHandler = dyn Fn(&mut Window, &mut App); + +/// Configuration for the dock's top-edge resize handle. +#[derive(Clone)] +pub struct BottomDockResizeConfig { + handle_height: Pixels, + on_resize_start: Rc, + on_resize: Option>, + on_resize_end: Option>, +} + +impl BottomDockResizeConfig { + /// Creates resize configuration with the standard interactive handle height. + pub fn new(on_resize_start: impl Fn(Pixels, &mut Window, &mut App) + 'static) -> Self { + Self { + handle_height: px(5.0), + on_resize_start: Rc::new(on_resize_start), + on_resize: None, + on_resize_end: None, + } + } + + /// Sets the interactive resize-handle height. + pub fn handle_height(mut self, handle_height: Pixels) -> Self { + self.handle_height = handle_height; + self + } + + /// Sets the callback invoked while the pointer moves inside the dock. + pub fn on_resize(mut self, on_resize: impl Fn(Pixels, &mut Window, &mut App) + 'static) -> Self { + self.on_resize = Some(Rc::new(on_resize)); + self + } + + /// Sets the callback invoked when a resize ends inside the dock. + pub fn on_resize_end(mut self, on_resize_end: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_resize_end = Some(Rc::new(on_resize_end)); + self + } +} + +/// Workbench-level overlay dock around one active tool's arbitrary content. +#[derive(IntoElement)] +pub struct BottomDock { + title: SharedString, + close: Entity, + height: Pixels, + theme: UIThemes, + resize: Option, + header_actions: Option, + child: Option, +} + +impl BottomDock { + /// Creates an empty dock for the active tool represented by `title`. + pub fn new(title: impl Into, close: Entity) -> Self { + Self { + title: title.into(), + close, + height: super::DEFAULT_BOTTOM_DOCK_HEIGHT, + theme: builtins::dark(), + resize: None, + header_actions: None, + child: None, + } + } + + /// Sets the dock height. + pub fn height(mut self, height: Pixels) -> Self { + self.height = height; + self + } + + /// Sets the semantic UI theme. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Enables top-edge resizing with the supplied callback configuration. + pub fn resizable(mut self, resize: BottomDockResizeConfig) -> Self { + self.resize = Some(resize); + self + } + + /// Adds tool-specific controls before the dock's close button. + pub fn header_actions(mut self, actions: impl IntoElement) -> Self { + self.header_actions = Some(actions.into_any_element()); + self + } + + /// Sets the active dock item's content. + pub fn child(mut self, child: impl IntoElement) -> Self { + self.child = Some(child.into_any_element()); + self + } +} + +impl RenderOnce for BottomDock { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let theme = self.theme; + div() + .absolute() + .left_0() + .right_0() + .bottom_0() + .flex() + .flex_col() + .h(self.height) + .min_h(self.height) + .border_t_1() + .border_color(theme.border.primary) + .bg(theme.background.primary) + .occlude() + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + .child( + div() + .flex() + .items_center() + .justify_between() + .h(px(34.0)) + .min_h(px(34.0)) + .px_3() + .border_b_1() + .border_color(theme.border.muted) + .child( + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(self.title), + ) + .child( + div() + .flex() + .items_center() + .gap_1() + .when_some(self.header_actions, |actions, header_actions| { + actions.child(header_actions) + }) + .child( + Button::new(self.close) + .theme(theme) + .variant(ButtonVariant::Transparent) + .size(ButtonSize::Small) + .style( + ButtonStyle::new() + .width(px(26.0)) + .height(px(24.0)) + .horizontal_padding(px(0.0)), + ) + .child(Icon::new("icons/window-close.svg").size(px(14.0)).theme(theme)), + ), + ), + ) + .when_some(self.child, |dock, child| dock.child(child)) + .when_some(self.resize, |dock, resize| { + let on_resize_start = resize.on_resize_start.clone(); + dock + .when_some(resize.on_resize, |dock, on_resize| { + dock.on_mouse_move(move |event, window, cx| { + on_resize(event.position.y, window, cx); + }) + }) + .when_some(resize.on_resize_end, |dock, on_resize_end| { + dock.on_mouse_up(MouseButton::Left, move |_, window, cx| { + on_resize_end(window, cx); + }) + }) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(resize.handle_height) + .cursor(CursorStyle::ResizeUpDown) + .hover(move |style| style.bg(theme.border.focus)) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + on_resize_start(event.position.y, window, cx); + }), + ) + }) + } +} diff --git a/crates/chitin-ui/src/composite/command_terminal/mod.rs b/crates/chitin-ui/src/composite/command_terminal/mod.rs new file mode 100644 index 00000000..704e4a84 --- /dev/null +++ b/crates/chitin-ui/src/composite/command_terminal/mod.rs @@ -0,0 +1,7 @@ +//! Structured command terminal built from terminal and text-input primitives. + +mod model; +mod render; + +pub use model::{CommandTerminalBlock, CommandTerminalId, CommandTerminalState, CommandTerminalStatus}; +pub use render::CommandTerminal; diff --git a/crates/chitin-ui/src/composite/command_terminal/model.rs b/crates/chitin-ui/src/composite/command_terminal/model.rs new file mode 100644 index 00000000..e3b9d495 --- /dev/null +++ b/crates/chitin-ui/src/composite/command_terminal/model.rs @@ -0,0 +1,149 @@ +//! GPUI state wrapper around the shared terminal transcript model. + +use std::collections::VecDeque; + +use chitin_terminal::{ + TerminalBlock, TerminalBlockId, TerminalBlockStatus, TerminalBuffer, TerminalLine, TerminalProfile, +}; +use gpui::{AppContext, Context, Entity}; + +use crate::primitive::{input::text::TextInputState, terminal::TerminalViewportState}; + +pub use chitin_terminal::{ + TerminalBlock as CommandTerminalBlock, TerminalBlockId as CommandTerminalId, + TerminalBlockStatus as CommandTerminalStatus, +}; + +/// Persistent command history, draft input, and viewport state. +pub struct CommandTerminalState { + input: Entity, + viewport: Entity, + buffer: TerminalBuffer, +} + +impl CommandTerminalState { + /// Creates an empty built-in-shell terminal with one live prompt. + pub fn new(prompt: TerminalLine, cx: &mut Context) -> Self { + Self::with_profile(TerminalProfile::BuiltinShell, prompt, cx) + } + + /// Creates a terminal state for an explicit terminal profile. + pub fn with_profile(profile: TerminalProfile, prompt: TerminalLine, cx: &mut Context) -> Self { + Self { + input: cx.new(TextInputState::new), + viewport: cx.new(TerminalViewportState::new), + buffer: TerminalBuffer::new(profile, prompt), + } + } + + /// Returns the profile represented by this terminal state. + pub const fn profile(&self) -> TerminalProfile { + self.buffer.profile() + } + + /// Returns the editable live-prompt input. + pub fn input(&self) -> &Entity { + &self.input + } + + /// Returns the terminal viewport state. + pub fn viewport(&self) -> &Entity { + &self.viewport + } + + /// Returns submitted command blocks in display order. + pub fn blocks(&self) -> &VecDeque { + self.buffer.blocks() + } + + /// Returns the prompt used for the next submission. + pub fn prompt(&self) -> &TerminalLine { + self.buffer.prompt() + } + + /// Returns whether a command currently owns the foreground slot. + pub const fn is_busy(&self) -> bool { + self.buffer.is_busy() + } + + /// Freezes the live prompt into a running command block. + pub fn begin_submission(&mut self, input: String, cx: &mut Context) -> TerminalBlockId { + let id = self.buffer.begin_submission(input); + self.input.update(cx, |input, cx| { + input.clear(cx); + input.set_disabled(true, cx); + }); + self.reveal_tail(cx); + id + } + + /// Appends a finished block for output that no command produced. + pub fn push_notice(&mut self, input: String, lines: Vec, cx: &mut Context) { + self.buffer.push_notice(input, lines); + self.reveal_tail(cx); + } + + /// Replaces transient output for one command without growing scrollback. + pub fn set_output(&mut self, id: TerminalBlockId, output: Vec, cx: &mut Context) { + if self + .buffer + .blocks() + .iter() + .find(|block| block.id == id) + .is_some_and(|block| block.output == output) + { + return; + } + self.buffer.set_output(id, output); + self.reveal_tail(cx); + } + + /// Finishes one block and restores the editable live prompt. + pub fn finish( + &mut self, + id: TerminalBlockId, + status: TerminalBlockStatus, + result: Vec, + cx: &mut Context, + ) { + let was_active = self.buffer.is_active(id); + self.buffer.finish(id, status, result); + if was_active { + self.input.update(cx, |input, cx| input.set_disabled(false, cx)); + } + self.reveal_tail(cx); + } + + /// Rejects one frozen input line and displays its error below the command. + pub fn reject(&mut self, id: TerminalBlockId, message: String, cx: &mut Context) { + self.finish( + id, + TerminalBlockStatus::Rejected, + vec![TerminalLine::new([chitin_terminal::TerminalSpan::error(format!( + "error: {message}" + ))])], + cx, + ); + } + + /// Clears submitted blocks and restores the editable live prompt. + pub fn clear(&mut self, cx: &mut Context) { + self.buffer.clear(); + self.input.update(cx, |input, cx| { + input.clear(cx); + input.set_disabled(false, cx); + }); + self.reveal_tail(cx); + } + + /// Replaces the prompt used by subsequent commands. + pub fn set_prompt(&mut self, prompt: TerminalLine) { + self.buffer.set_prompt(prompt); + } + + /// Requests that the newest command and live prompt remain visible. + fn reveal_tail(&self, cx: &mut Context) { + self.viewport.update(cx, |viewport, _| viewport.reveal_tail()); + cx.notify(); + } +} diff --git a/crates/chitin-ui/src/composite/command_terminal/render.rs b/crates/chitin-ui/src/composite/command_terminal/render.rs new file mode 100644 index 00000000..087c4053 --- /dev/null +++ b/crates/chitin-ui/src/composite/command_terminal/render.rs @@ -0,0 +1,119 @@ +//! Rendering for structured command blocks and the live prompt. + +use gpui::{App, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Window, div, prelude::*}; + +use super::CommandTerminalState; +use crate::{ + primitive::{ + input::text::{TextInput, TextInputSize, TextInputStyle, TextInputVariant}, + terminal::{TerminalLine, TerminalSpan, TerminalViewport}, + }, + themes::{UIThemes, builtins}, +}; + +/// A structured shell transcript whose editable prompt lives at its tail. +#[derive(IntoElement)] +pub struct CommandTerminal { + state: Entity, + theme: UIThemes, + font_family: SharedString, +} + +impl CommandTerminal { + /// Creates a command terminal bound to persistent command-block state. + pub fn new(state: Entity) -> Self { + Self { + state, + theme: builtins::dark(), + font_family: "monospace".into(), + } + } + + /// Sets the semantic UI theme. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Sets the monospace font family used by transcript and input. + pub fn font_family(mut self, font_family: impl Into) -> Self { + self.font_family = font_family.into(); + self + } +} + +impl RenderOnce for CommandTerminal { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.read(cx); + let input = state.input().clone(); + let viewport = state.viewport().clone(); + let prompt = state.prompt().clone(); + let busy = state.is_busy(); + let lines = state.blocks().iter().flat_map(block_lines).collect::>(); + let theme = self.theme; + let tail = (!busy).then(|| render_live_prompt(prompt, input, theme)); + + TerminalViewport::new(viewport) + .theme(theme) + .font_family(self.font_family) + .lines(lines) + .when_some(tail, TerminalViewport::tail) + } +} + +/// Flattens one command block while preserving command-local output ordering. +fn block_lines(block: &super::CommandTerminalBlock) -> Vec { + let mut command = block.prompt.clone(); + command.push(TerminalSpan::primary(block.input.clone())); + let mut lines = Vec::with_capacity(block.output.len() + block.result.len() + 2); + lines.push(command); + lines.extend(block.output.iter().cloned()); + lines.extend(block.result.iter().cloned()); + lines.push(TerminalLine::default()); + lines +} + +/// Renders the only editable line at the end of terminal scrollback. +fn render_live_prompt( + prompt: TerminalLine, + input: Entity, + theme: UIThemes, +) -> impl IntoElement { + div() + .flex() + .items_center() + .min_h(gpui::px(26.0)) + .children( + prompt + .spans() + .iter() + .map(|span| div().text_color(tone_color(span.tone, theme)).child(span.text.clone())), + ) + .child( + TextInput::new(input) + .theme(theme) + .variant(TextInputVariant::Transparent) + .size(TextInputSize::Small) + .style( + TextInputStyle::new() + .background(theme.background.primary) + .border(theme.background.primary) + .focus_border(theme.background.primary) + .horizontal_padding(gpui::px(0.0)), + ) + .full_width(true), + ) +} + +/// Resolves a frontend-independent terminal tone through the active UI theme. +fn tone_color(tone: chitin_terminal::TerminalTone, theme: UIThemes) -> gpui::Rgba { + match tone { + chitin_terminal::TerminalTone::Primary => theme.text.primary, + chitin_terminal::TerminalTone::Secondary => theme.text.secondary, + chitin_terminal::TerminalTone::Accent => theme.accent.primary, + chitin_terminal::TerminalTone::Success => theme.text.success, + chitin_terminal::TerminalTone::Warning => theme.text.warning, + chitin_terminal::TerminalTone::Error => theme.text.error, + chitin_terminal::TerminalTone::Info => theme.text.info, + } +} diff --git a/crates/chitin-ui/src/composite/mod.rs b/crates/chitin-ui/src/composite/mod.rs index d6727cac..fb239a6a 100644 --- a/crates/chitin-ui/src/composite/mod.rs +++ b/crates/chitin-ui/src/composite/mod.rs @@ -3,6 +3,12 @@ /// Vertical activity-bar composition. pub mod activity_bar; +/// Resizable workbench dock for terminal, tasks, output, and similar tools. +pub mod bottom_dock; + +/// Structured command terminal assembled from terminal and input primitives. +pub mod command_terminal; + /// Independently single-selectable groups composed from select primitives. pub mod grouped_select; diff --git a/crates/chitin-ui/src/primitive/input/select/render.rs b/crates/chitin-ui/src/primitive/input/select/render.rs index 057d0af3..c6e3c98e 100644 --- a/crates/chitin-ui/src/primitive/input/select/render.rs +++ b/crates/chitin-ui/src/primitive/input/select/render.rs @@ -24,6 +24,8 @@ const DEFAULT_PADDING_X: Pixels = px(8.0); const DEFAULT_MENU_MAX_HEIGHT: Pixels = px(240.0); /// Default height of each item const ITEM_HEIGHT: Pixels = px(30.0); +/// Default rendered size of the icon shown inside a trigger. +const DEFAULT_TRIGGER_ICON_SIZE: Pixels = px(14.0); /// Default height of each label in the items, notice it's different from the /// font size of label in the items. const LABEL_HEIGHT: Pixels = px(24.0); @@ -47,8 +49,14 @@ pub enum SelectInputVariant { pub struct SelectInputStyle { /// Explicit trigger width, when visual layout requires one. width: Option, + /// Optional horizontal padding for a compact trigger. + trigger_padding_x: Option, + /// Optional popup width independent of a compact trigger. + menu_width: Option, /// Maximum popup height before its content scrolls. menu_max_height: Option, + /// Optional rendered size for the trigger's leading icon. + trigger_icon_size: Option, } impl SelectInputStyle { @@ -63,11 +71,29 @@ impl SelectInputStyle { self } + /// Sets a popup width independent of the trigger width. + pub fn menu_width(mut self, width: Pixels) -> Self { + self.menu_width = Some(width); + self + } + + /// Sets the trigger's horizontal padding. + pub fn trigger_padding_x(mut self, padding: Pixels) -> Self { + self.trigger_padding_x = Some(padding); + self + } + /// Sets the maximum visible popup height before its option list scrolls. pub fn menu_max_height(mut self, height: Pixels) -> Self { self.menu_max_height = Some(height); self } + + /// Sets the rendered size of the trigger's leading icon. + pub fn trigger_icon_size(mut self, size: Pixels) -> Self { + self.trigger_icon_size = Some(size); + self + } } /// Visual size for a [`Select`]. @@ -233,10 +259,13 @@ impl RenderOnce for Select { let popup_layout = SelectPopupLayout { trigger: metrics, configured_width: width, + menu_width: self.style.menu_width, + align_end: self.content.align_end, trigger_bounds, viewport_size, }; let value = self.trigger.value; + let show_indicator = self.trigger.show_indicator; div() .relative() @@ -255,7 +284,7 @@ impl RenderOnce for Select { .items_center() .justify_between() .h(metrics.height) - .px(DEFAULT_PADDING_X) + .px(self.style.trigger_padding_x.unwrap_or(DEFAULT_PADDING_X)) .rounded_sm() .border_1() .border_color(colors.border) @@ -286,12 +315,16 @@ impl RenderOnce for Select { .track_focus(&focus_handle) .child(render_value( value, + self.trigger.icon, selected_label, selected_icon, + self.style.trigger_icon_size.unwrap_or(DEFAULT_TRIGGER_ICON_SIZE), colors.foreground, self.theme.text.disabled, )) - .child(Icon::new("icons/tree-expand.svg").theme(self.theme)), + .when(show_indicator, |trigger| { + trigger.child(Icon::new("icons/tree-expand.svg").theme(self.theme)) + }), ), ) .when(open, |parent| { @@ -323,10 +356,24 @@ impl RenderOnce for Select { } /// Trigger popup panel for a [`Select`]. -#[derive(Clone, Default)] +#[derive(Clone)] pub struct SelectTrigger { /// Value subtree displayed before the trigger icon. value: SelectValue, + /// Optional icon shown when the selector has no selected value. + icon: Option, + /// Whether the trigger draws the default trailing chevron. + show_indicator: bool, +} + +impl Default for SelectTrigger { + fn default() -> Self { + Self { + value: SelectValue::default(), + icon: None, + show_indicator: true, + } + } } impl SelectTrigger { @@ -340,6 +387,18 @@ impl SelectTrigger { self.value = value; self } + + /// Sets the icon shown while the selector has no selected value. + pub fn icon(mut self, path: impl Into) -> Self { + self.icon = Some(path.into()); + self + } + + /// Shows or hides the default trailing chevron. + pub fn show_indicator(mut self, show: bool) -> Self { + self.show_indicator = show; + self + } } /// Displays the selected label or a placeholder inside a [`SelectTrigger`]. @@ -378,6 +437,8 @@ pub struct SelectContent { position: SelectContentPosition, /// Ordered content nodes used for rendering and selection-state flattening. children: Vec, + /// Whether the popup's right edge aligns with the trigger's right edge. + align_end: bool, } impl SelectContent { @@ -392,6 +453,12 @@ impl SelectContent { self } + /// Aligns the popup's right edge with the trigger's right edge. + pub fn align_end(mut self) -> Self { + self.align_end = true; + self + } + /// Adds one grouped section to this content. pub fn group(mut self, group: SelectGroup) -> Self { self.children.push(SelectContentChild::Group(group)); @@ -638,8 +705,11 @@ impl SelectSeparator { /// # Parameters /// /// * `value` configures the placeholder displayed for an empty selection. +/// * `trigger_icon` is the icon shown while the selector has no selection. /// * `selected_label` is the current selected item label, when available. -/// * `foreground` colors selected text. +/// * `selected_icon` is the icon owned by the selected item, when available. +/// * `icon_size` is the rendered size of whichever icon is displayed. +/// * `foreground` colors selected text and icons. /// * `placeholder` colors the empty-state text. /// /// # Returns @@ -647,12 +717,16 @@ impl SelectSeparator { /// A flexible value element that leaves room for the trigger icon. fn render_value( value: SelectValue, + trigger_icon: Option, selected_label: Option, selected_icon: Option, + icon_size: Pixels, foreground: gpui::Rgba, placeholder: gpui::Rgba, ) -> Div { let selected = selected_label.is_some(); + let icon = selected_icon.or(trigger_icon); + let label = selected_label.unwrap_or(value.placeholder); let mut value_element = div() .flex() .items_center() @@ -660,10 +734,20 @@ fn render_value( .min_w_0() .flex_1() .text_color(if selected { foreground } else { placeholder }); - if let Some(path) = selected_icon { - value_element = value_element.child(Icon::new(path).size(px(14.0)).color(foreground)); + // An icon-only trigger carries no text to lay out, so centering the icon keeps + // it off the padding edge instead of left-aligning it against an empty label. + if label.is_empty() { + return match icon { + Some(path) => value_element + .justify_center() + .child(Icon::new(path).size(icon_size).color(foreground)), + None => value_element.child(label), + }; + } + if let Some(path) = icon { + value_element = value_element.child(Icon::new(path).size(icon_size).color(foreground)); } - value_element.child(selected_label.unwrap_or(value.placeholder)) + value_element.child(label) } /// Renders one popup content tree at its selected positioning mode. @@ -757,7 +841,12 @@ fn render_content( .theme(theme) .scroll_handle(interaction.scroll_handle); let popover = if let Some(trigger_bounds) = layout.trigger_bounds { - popover.anchor_position(trigger_bounds.origin) + let origin = if layout.align_end { + point(trigger_bounds.right() - layout.popup_width(), trigger_bounds.top()) + } else { + trigger_bounds.origin + }; + popover.anchor_position(origin) } else { popover }; @@ -911,6 +1000,10 @@ struct SelectPopupLayout { trigger: SelectInputMetrics, /// Configured width used until the trigger has been measured. configured_width: Pixels, + /// Explicit menu width for compact triggers. + menu_width: Option, + /// Whether the popup aligns its right edge with the trigger. + align_end: bool, /// Trigger bounds measured during the previous prepaint pass. trigger_bounds: Option>, /// Current window size used to constrain item-aligned content. @@ -1050,9 +1143,11 @@ impl SelectPopupLayout { /// Returns the measured trigger width, falling back before the first prepaint pass. fn popup_width(self) -> Pixels { - self - .trigger_bounds - .map_or(self.configured_width, |trigger_bounds| trigger_bounds.size.width) + self.menu_width.unwrap_or_else(|| { + self + .trigger_bounds + .map_or(self.configured_width, |trigger_bounds| trigger_bounds.size.width) + }) } /// Returns the measured trigger height used by conventional anchor placement. @@ -1112,7 +1207,7 @@ impl SelectInputColors { background: theme.background.primary, hover_background: theme.background.hover, foreground: theme.text.primary, - border: theme.border.muted, + border: builtins::TRANSPARENT, focus_border: theme.border.focus, }, } @@ -1197,6 +1292,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(20.0)), size: Size { @@ -1232,6 +1329,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(650.0)), size: Size { @@ -1267,6 +1366,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(300.0)), size: Size { @@ -1299,6 +1400,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(580.0)), size: Size { @@ -1329,6 +1432,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(0.0)), size: Size { @@ -1353,6 +1458,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(300.0)), size: Size { diff --git a/crates/chitin-ui/src/primitive/input/select/state.rs b/crates/chitin-ui/src/primitive/input/select/state.rs index be63a2c7..3ac89e74 100644 --- a/crates/chitin-ui/src/primitive/input/select/state.rs +++ b/crates/chitin-ui/src/primitive/input/select/state.rs @@ -260,6 +260,11 @@ impl SelectInputState { true } + /// Clears the current selection without changing the available options. + pub fn clear_selection(&mut self, cx: &mut Context) { + self.set_selected_id(None, cx); + } + /// Updates selector availability, closing the popup and emitting semantic events when it changes. pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { if self.disabled == disabled { diff --git a/crates/chitin-ui/src/primitive/mod.rs b/crates/chitin-ui/src/primitive/mod.rs index 80477095..36c4c9e8 100644 --- a/crates/chitin-ui/src/primitive/mod.rs +++ b/crates/chitin-ui/src/primitive/mod.rs @@ -14,8 +14,12 @@ pub mod popover; pub mod progress; /// Generic resize gesture state. pub mod resize; +/// Draggable scrollbars reporting viewport positions. +pub mod scrollbar; /// Sidebar layout and resize controls. pub mod sidebar; +/// Monospace terminal rows and tail-following scroll viewport. +pub mod terminal; /// Text display controls with optional clipboard support. pub mod text; /// Virtualized tree layout and scroll state. diff --git a/crates/chitin-ui/src/primitive/scrollbar/event.rs b/crates/chitin-ui/src/primitive/scrollbar/event.rs new file mode 100644 index 00000000..c2d64143 --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/event.rs @@ -0,0 +1,13 @@ +//! Semantic events published by the scrollbar primitive. + +use gpui::Pixels; + +/// A viewport motion requested by a scrollbar interaction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScrollbarEvent { + /// The user moved the viewport to this offset in the caller's pixel space. + /// + /// The offset follows GPUI's scroll convention: zero shows the start of the + /// content and it becomes more negative as the viewport moves toward the end. + Scrolled { offset: Pixels }, +} diff --git a/crates/chitin-ui/src/primitive/scrollbar/metrics.rs b/crates/chitin-ui/src/primitive/scrollbar/metrics.rs new file mode 100644 index 00000000..8f6a13cc --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/metrics.rs @@ -0,0 +1,262 @@ +//! Scrollbar geometry, expressed in the caller's pixel space. +//! +//! The math here is deliberately free of GPUI layout types beyond [`Pixels`], +//! so thumb sizing and drag mapping can be tested without a window. + +use gpui::{Pixels, px}; + +/// Smallest fraction of the track a thumb keeps. +/// +/// A long scrollback would otherwise shrink the thumb past the point where it +/// can be grabbed. The floor is applied to the mapping as well as the paint, so +/// the far end of the content stays reachable. +const MIN_THUMB_EXTENT: f32 = 0.05; + +/// Geometry of the region a scrollbar moves a viewport through. +/// +/// The offset follows GPUI's scroll convention: zero shows the start of the +/// content and it becomes more negative as the viewport moves toward the end. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScrollbarMetrics { + /// Total height of the scrollable content. + pub content_height: Pixels, + /// Height of the visible viewport. + pub viewport_height: Pixels, + /// Distance from the top of the content to the top of the viewport. + pub offset: Pixels, +} + +impl ScrollbarMetrics { + /// Creates metrics for one viewport position. + /// + /// # Parameters + /// + /// * `content_height` is the total height of the scrollable content. + /// * `viewport_height` is the height of the visible viewport. + /// * `offset` is the distance from the top of the content to the top of the + /// viewport, which is negative once the viewport has moved down. + /// + /// # Returns + /// + /// Metrics describing the requested viewport position. + pub const fn new(content_height: Pixels, viewport_height: Pixels, offset: Pixels) -> Self { + Self { + content_height, + viewport_height, + offset, + } + } + + /// Returns whether the content is taller than the viewport. + pub fn is_scrollable(&self) -> bool { + self.travel() > 0.0 + } + + /// Returns the fraction of the track the thumb occupies. + pub fn thumb_extent_fraction(&self) -> f32 { + let content = f32::from(self.content_height); + if content <= 0.0 { + return 1.0; + } + + (f32::from(self.viewport_height) / content).clamp(MIN_THUMB_EXTENT, 1.0) + } + + /// Returns the thumb's distance from the top of the track, as a fraction of + /// the track that the thumb can travel over. + pub fn thumb_top_fraction(&self) -> f32 { + let travel = self.travel(); + if travel <= 0.0 { + return 0.0; + } + + let progress = (-f32::from(self.offset) / travel).clamp(0.0, 1.0); + progress * self.thumb_reach() + } + + /// Returns the viewport offset described by a thumb dragged to `top`. + /// + /// # Parameters + /// + /// * `top` is the thumb's distance from the top of the track, as a fraction + /// of the track. Values outside the reachable range land on its ends. + /// + /// # Returns + /// + /// The viewport offset in the caller's pixel space. + pub fn offset_for_thumb_top(&self, top: f32) -> Pixels { + let travel = self.travel(); + let reach = self.thumb_reach(); + if travel <= 0.0 || reach <= 0.0 { + return Pixels::ZERO; + } + + px(-(top / reach).clamp(0.0, 1.0) * travel) + } + + /// Returns the viewport offset for a click at a point on the track. + /// + /// The click centres the thumb on itself, which is what a scrollbar track + /// click does everywhere else. + /// + /// # Parameters + /// + /// * `fraction` is the clicked distance from the top of the track, as a + /// fraction of the track. + /// + /// # Returns + /// + /// The viewport offset in the caller's pixel space. + pub fn offset_for_track_click(&self, fraction: f32) -> Pixels { + let reach = self.thumb_reach(); + let top = (fraction - self.thumb_extent_fraction() / 2.0).clamp(0.0, reach); + self.offset_for_thumb_top(top) + } + + /// Returns the offset one viewport closer to the end of the content. + pub fn offset_for_page_toward_end(&self) -> Pixels { + self.offset_for_page(true) + } + + /// Returns the offset one viewport closer to the start of the content. + pub fn offset_for_page_toward_start(&self) -> Pixels { + self.offset_for_page(false) + } + + /// Returns the offset one viewport away from the current position. + fn offset_for_page(&self, toward_end: bool) -> Pixels { + let travel = self.travel(); + let step = if toward_end { + f32::from(self.viewport_height) + } else { + -f32::from(self.viewport_height) + }; + + px(-(-f32::from(self.offset) + step).clamp(0.0, travel)) + } + + /// Returns the distance the viewport can travel through the content. + fn travel(&self) -> f32 { + (f32::from(self.content_height) - f32::from(self.viewport_height)).max(0.0) + } + + /// Returns how far the thumb's top may travel, as a track fraction. + fn thumb_reach(&self) -> f32 { + 1.0 - self.thumb_extent_fraction() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds metrics for content that is scrolled to a given pixel offset. + fn metrics(content: f32, viewport: f32, offset: f32) -> ScrollbarMetrics { + ScrollbarMetrics::new(px(content), px(viewport), px(offset)) + } + + #[test] + fn content_that_fits_should_not_scroll() { + assert!(!metrics(80.0, 200.0, 0.0).is_scrollable()); + } + + #[test] + fn a_thumb_should_span_the_whole_track_when_nothing_scrolls() { + let metrics = metrics(80.0, 200.0, 0.0); + + assert_eq!(metrics.thumb_extent_fraction(), 1.0); + assert_eq!(metrics.thumb_top_fraction(), 0.0); + } + + #[test] + fn a_thumb_should_shrink_as_the_content_grows() { + let some_history = metrics(124.0, 24.0, 0.0).thumb_extent_fraction(); + let more_history = metrics(100_024.0, 24.0, 0.0).thumb_extent_fraction(); + + assert!((some_history - 24.0 / 124.0).abs() < 1e-6); + assert!( + more_history < some_history, + "a longer history should give a smaller thumb" + ); + assert_eq!(metrics(48.0, 24.0, 0.0).thumb_extent_fraction(), 0.5); + } + + #[test] + fn a_thumb_should_keep_a_grabbable_size_for_a_long_history() { + assert_eq!(metrics(100_024.0, 24.0, 0.0).thumb_extent_fraction(), MIN_THUMB_EXTENT); + } + + #[test] + fn a_thumb_should_rest_at_the_start_of_the_track_at_the_first_offset() { + let metrics = metrics(200.0, 100.0, 0.0); + + assert_eq!(metrics.thumb_top_fraction(), 0.0); + } + + #[test] + fn a_thumb_should_rest_at_the_end_of_the_track_at_the_last_offset() { + let metrics = metrics(200.0, 100.0, -100.0); + + // Half the content is visible, so the thumb is half the track and sits at + // the end of the half it can travel. + assert_eq!(metrics.thumb_extent_fraction(), 0.5); + assert_eq!(metrics.thumb_top_fraction(), 0.5); + } + + #[test] + fn an_offset_should_round_trip_through_the_thumb() { + let metrics = metrics(400.0, 100.0, -90.0); + let top = metrics.thumb_top_fraction(); + + assert_eq!(metrics.offset_for_thumb_top(top), px(-90.0)); + } + + #[test] + fn dragging_toward_the_end_should_ask_for_a_later_offset() { + let metrics = metrics(400.0, 100.0, 0.0); + + let earlier = metrics.offset_for_thumb_top(0.25); + let later = metrics.offset_for_thumb_top(0.75); + + assert!(later < earlier, "a lower thumb should ask for a more negative offset"); + assert!(earlier < Pixels::ZERO); + } + + #[test] + fn a_drag_past_either_end_should_land_on_it() { + let metrics = metrics(400.0, 100.0, -150.0); + + assert_eq!(metrics.offset_for_thumb_top(-5.0), Pixels::ZERO); + assert_eq!(metrics.offset_for_thumb_top(5.0), px(-300.0)); + } + + #[test] + fn a_track_click_should_centre_the_thumb_on_the_click() { + let metrics = metrics(400.0, 100.0, 0.0); + + // A click at the midpoint of the track should land the viewport halfway + // through the content, with the thumb centred under the pointer. + let offset = metrics.offset_for_track_click(0.5); + + assert_eq!(offset, px(-150.0)); + // Halfway through the content means the thumb's top is at half of its reach. + let centred = ScrollbarMetrics::new(metrics.content_height, metrics.viewport_height, offset); + assert_eq!(centred.thumb_top_fraction(), 0.375); + } + + #[test] + fn a_track_click_should_not_escape_the_content() { + let metrics = metrics(400.0, 100.0, 0.0); + + assert_eq!(metrics.offset_for_track_click(0.0), Pixels::ZERO); + assert_eq!(metrics.offset_for_track_click(1.0), px(-300.0)); + } + + #[test] + fn a_content_that_fits_should_ignore_every_motion() { + let metrics = metrics(80.0, 200.0, 0.0); + + assert_eq!(metrics.offset_for_thumb_top(0.5), Pixels::ZERO); + assert_eq!(metrics.offset_for_track_click(0.5), Pixels::ZERO); + } +} diff --git a/crates/chitin-ui/src/primitive/scrollbar/mod.rs b/crates/chitin-ui/src/primitive/scrollbar/mod.rs new file mode 100644 index 00000000..c2950e63 --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/mod.rs @@ -0,0 +1,14 @@ +//! A draggable scrollbar reporting where a viewport should sit. +//! +//! [`ScrollbarState`] owns the drag. Consumers subscribe to [`ScrollbarEvent`] +//! values rather than attaching component-specific pointer handlers. + +mod event; +mod metrics; +mod render; +mod state; + +pub use event::ScrollbarEvent; +pub use metrics::ScrollbarMetrics; +pub use render::{Scrollbar, ScrollbarSize}; +pub use state::ScrollbarState; diff --git a/crates/chitin-ui/src/primitive/scrollbar/render.rs b/crates/chitin-ui/src/primitive/scrollbar/render.rs new file mode 100644 index 00000000..3f3eab60 --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/render.rs @@ -0,0 +1,182 @@ +//! Rendering and visual customization for the scrollbar primitive. + +use gpui::{ + App, CursorStyle, Entity, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, + Window, div, prelude::*, px, relative, +}; + +use super::{ScrollbarMetrics, ScrollbarState}; +use crate::themes::{UIThemes, builtins}; + +/// Track width and thumb width for one visual size of a [`Scrollbar`]. +#[derive(Clone, Copy, Debug)] +struct ScrollbarSizeMetrics { + /// Width of the interactive track along the trailing edge. + lane_width: Pixels, + /// Width of the painted thumb inside the track. + thumb_width: Pixels, +} + +/// Visual size for a [`Scrollbar`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ScrollbarSize { + /// Narrow bar for dense panes. + Small, + /// Default bar. + #[default] + Medium, +} + +impl ScrollbarSize { + /// Returns the track and thumb widths for this size. + fn metrics(self) -> ScrollbarSizeMetrics { + match self { + Self::Small => ScrollbarSizeMetrics { + lane_width: px(9.0), + thumb_width: px(4.0), + }, + Self::Medium => ScrollbarSizeMetrics { + lane_width: px(13.0), + thumb_width: px(6.0), + }, + } + } +} + +/// A draggable bar that reports where a viewport should sit. +/// +/// The bar is laid out over the viewport it scrolls and pinned to the trailing +/// edge, so its height is the viewport height. It paints nothing while the +/// content fits, and while idle it only occupies its lane, leaving the rest of +/// the area free for the content underneath. +#[derive(IntoElement)] +pub struct Scrollbar { + state: Entity, + metrics: ScrollbarMetrics, + theme: UIThemes, + size: ScrollbarSize, +} + +impl Scrollbar { + /// Creates a scrollbar that reports motions through `state`. + pub fn new(state: Entity) -> Self { + Self { + state, + metrics: ScrollbarMetrics::new(Pixels::ZERO, Pixels::ZERO, Pixels::ZERO), + theme: builtins::dark(), + size: ScrollbarSize::default(), + } + } + + /// Sets the content geometry this bar describes. + pub fn metrics(mut self, metrics: ScrollbarMetrics) -> Self { + self.metrics = metrics; + self + } + + /// Sets the semantic theme used for this bar. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Sets the visual size. + pub fn size(mut self, size: ScrollbarSize) -> Self { + self.size = size; + self + } +} + +impl RenderOnce for Scrollbar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let dragging = self.state.read(cx).is_dragging(); + let mut root = div().absolute().inset_0(); + if !self.metrics.is_scrollable() { + return root; + } + + let size = self.size.metrics(); + let theme = self.theme; + let metrics = self.metrics; + let thumb_top = metrics.thumb_top_fraction(); + let thumb_extent = metrics.thumb_extent_fraction(); + + let thumb_color: Hsla = if dragging { + theme.text.secondary + } else { + theme.border.tertiary + } + .into(); + + let state_for_thumb = self.state.clone(); + let state_for_before = self.state.clone(); + let state_for_after = self.state.clone(); + let state_for_move = self.state.clone(); + let state_for_up = self.state.clone(); + + // While dragging, the pointer is free to leave the narrow lane, so the whole + // area takes the move and release. This layer only exists during a drag; + // an idle bar inserts no hitbox here and the content underneath keeps its + // own pointer behaviour. + if dragging { + root = root + .on_mouse_move(move |event, _, cx| { + state_for_move.update(cx, |state, cx| { + state.drag(event.position.y, event.pressed_button, metrics, cx); + }); + }) + .on_mouse_up(MouseButton::Left, move |_, _, cx| { + state_for_up.update(cx, |state, cx| state.end_drag(cx)); + }); + } + + root.child( + div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(size.lane_width) + .cursor(CursorStyle::Arrow) + .hover(move |style| style.bg(theme.background.hover)) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(relative(thumb_top)) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + state_for_before.update(cx, |state, cx| state.scroll_page(metrics, false, cx)); + cx.stop_propagation(); + }), + ) + .child( + div() + .absolute() + .top(relative(thumb_top)) + .h(relative(thumb_extent)) + .right(px(3.0)) + .w(size.thumb_width) + .rounded_full() + .bg(thumb_color) + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + state_for_thumb.update(cx, |state, cx| state.begin_drag(event.position.y, metrics, cx)); + cx.stop_propagation(); + }), + ) + .child( + div() + .absolute() + .top(relative(thumb_top + thumb_extent)) + .bottom_0() + .left_0() + .right_0() + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + state_for_after.update(cx, |state, cx| state.scroll_page(metrics, true, cx)); + cx.stop_propagation(); + }), + ), + ) + } +} diff --git a/crates/chitin-ui/src/primitive/scrollbar/state.rs b/crates/chitin-ui/src/primitive/scrollbar/state.rs new file mode 100644 index 00000000..60ea3a0f --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/state.rs @@ -0,0 +1,112 @@ +//! Persistent interaction state for the scrollbar primitive. + +use gpui::{Context, EventEmitter, MouseButton, Pixels}; + +use super::{ScrollbarEvent, ScrollbarMetrics}; +use crate::primitive::resize::ResizeGesture; + +/// Persistent interaction state for one scrollbar. +/// +/// The bar owns its drag: callers subscribe to [`ScrollbarEvent`] instead of +/// attaching their own pointer handlers. +pub struct ScrollbarState { + drag: Option>, +} + +impl ScrollbarState { + /// Creates a scrollbar that is not being dragged. + pub fn new(_cx: &mut Context) -> Self { + Self { drag: None } + } + + /// Returns whether the user is currently dragging the thumb. + pub const fn is_dragging(&self) -> bool { + self.drag.is_some() + } + + /// Starts a drag from a press on the thumb. + /// + /// A press that arrives while a drag is already underway is ignored, so the + /// thumb cannot be handed to a second pointer mid-gesture. + /// + /// # Parameters + /// + /// * `pointer_y` is where the pointer pressed the thumb, in window pixels. + /// * `metrics` describes the content the bar is scrolling. + /// * `cx` refreshes observers of the drag state. + pub fn begin_drag(&mut self, pointer_y: Pixels, metrics: ScrollbarMetrics, cx: &mut Context) { + if self.is_dragging() { + return; + } + + // The grip is kept as a track fraction rather than a pixel delta so it + // survives the content growing underneath the drag. + self.drag = Some(ResizeGesture::new((), pointer_y, metrics.thumb_top_fraction())); + cx.notify(); + } + + /// Continues an in-progress drag from the current pointer position. + /// + /// This is a no-op when no drag is underway, which is what lets one handler be + /// attached to the whole bar without acting on idle pointer movement. + /// + /// # Parameters + /// + /// * `pointer_y` is the pointer position on the drag axis, in window pixels. + /// * `pressed_button` is the button the move reports as held, if any. + /// * `metrics` describes the content the bar is scrolling. The bar is laid out + /// over the viewport, so the viewport height is the distance the thumb + /// travels over. + /// * `cx` publishes the requested viewport offset. + pub fn drag( + &mut self, + pointer_y: Pixels, + pressed_button: Option, + metrics: ScrollbarMetrics, + cx: &mut Context, + ) { + // A release outside the bar never reaches its own handler: GPUI dispatches + // pointer events by position and offers no pointer capture, so a gesture + // would otherwise stay open and the next move across the bar would jump the + // viewport to wherever that move implied. The button the move reports is + // what closes it instead. + if pressed_button != Some(MouseButton::Left) { + self.end_drag(cx); + return; + } + let Some(gesture) = self.drag.as_ref() else { + return; + }; + + let track_height = f32::from(metrics.viewport_height).max(1.0); + let thumb_top = gesture.start_value() + gesture.delta(pointer_y) / track_height; + cx.emit(ScrollbarEvent::Scrolled { + offset: metrics.offset_for_thumb_top(thumb_top), + }); + } + + /// Ends an in-progress drag. + pub fn end_drag(&mut self, cx: &mut Context) { + if self.drag.take().is_some() { + cx.notify(); + } + } + + /// Moves the viewport by one screenful toward one end of the content. + /// + /// # Parameters + /// + /// * `metrics` describes the content the bar is scrolling. + /// * `toward_end` selects the direction of travel. + /// * `cx` publishes the requested viewport offset. + pub fn scroll_page(&self, metrics: ScrollbarMetrics, toward_end: bool, cx: &mut Context) { + let offset = if toward_end { + metrics.offset_for_page_toward_end() + } else { + metrics.offset_for_page_toward_start() + }; + cx.emit(ScrollbarEvent::Scrolled { offset }); + } +} + +impl EventEmitter for ScrollbarState {} diff --git a/crates/chitin-ui/src/primitive/terminal/emulator.rs b/crates/chitin-ui/src/primitive/terminal/emulator.rs new file mode 100644 index 00000000..60e4d1a2 --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/emulator.rs @@ -0,0 +1,1327 @@ +//! GPUI rendering and input handling for a VT terminal session. + +use std::{ops::Range, sync::Arc}; + +use chitin_terminal::{ + TerminalCell, TerminalCellAttributes, TerminalColor, TerminalEvent, TerminalNamedColor, TerminalScroll, + TerminalScrollState, TerminalSession, TerminalSessionError, TerminalSize, TerminalSnapshot, +}; +use gpui::{ + AnyElement, App, AsyncApp, Bounds, Context, Div, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, + EventEmitter, FocusHandle, Font, FontFallbacks, FontFeatures, FontWeight, GlobalElementId, Hsla, InspectorElementId, + InteractiveElement, IntoElement, KeyDownEvent, Keystroke, LayoutId, MouseButton, ParentElement, Pixels, Point, + RenderOnce, ScrollDelta, ScrollWheelEvent, SharedString, Styled, Subscription, Task, TextRun, UTF16Selection, + WeakEntity, Window, div, font, point, prelude::*, px, rgb, size, +}; + +use crate::{ + primitive::scrollbar::{Scrollbar, ScrollbarEvent, ScrollbarMetrics, ScrollbarState}, + themes::{UIThemes, builtins}, +}; + +const FONT_SIZE: f32 = 15.0; +const LINE_HEIGHT_MULTIPLIER: f32 = 1.4; +/// Largest viewport motion one wheel event may ask for. +/// +/// A platform is free to report a very large pixel delta, and one such event +/// should not throw the viewport across the whole scrollback. +const MAX_WHEEL_LINES_PER_EVENT: i32 = 10; + +/// Semantic events emitted by the terminal emulator primitive. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalEmulatorEvent { + /// Input bytes were delivered to the active terminal backend. + InputWritten, + /// The terminal backend stopped and reported its exit code. + Exited { + /// Code the backend reported for the program that ended. + code: u32, + }, +} + +/// Persistent focus, backend, and VT state for a rendered terminal emulator. +pub struct TerminalEmulatorState { + session: TerminalSession, + focus: FocusHandle, + size: TerminalSize, + metrics: TerminalMetrics, + /// Message painted below the grid, describing a backend that failed or stopped. + status: Option, + /// Exit code once the backend has stopped, which is also what ends its input. + ended: Option, + preedit: Option, + /// Where the viewport sits in the scrollback, refreshed when events arrive. + scroll_state: TerminalScrollState, + /// Sub-line part of a pixel scroll, carried so a slow trackpad still moves. + scroll_remainder: f32, + scrollbar: Entity, + _scrollbar_subscription: Subscription, + _event_task: Task<()>, +} + +/// Composition text an input method is still editing. +/// +/// The composition is deliberately kept out of the session. Writing half-finished +/// bytes to a PTY or an in-process shell would let the child program echo and +/// interpret them, so the text stays here and is painted over the grid until the +/// input method commits it. +#[derive(Clone, Debug)] +struct TerminalPreedit { + /// Composition text the input method is currently showing. + text: String, + /// Grid row the composition started on. + row: usize, + /// Grid column the composition started at. + column: usize, +} + +impl TerminalEmulatorState { + /// Creates state around an existing terminal session. + pub fn new(session: TerminalSession, size: TerminalSize, cx: &mut Context) -> Self { + let wakeups = session.event_wakeups(); + let event_task = cx.spawn(|this: WeakEntity, async_cx: &mut AsyncApp| { + let mut async_cx = async_cx.clone(); + async move { + while wakeups.recv().await.is_ok() { + let should_continue = this + .update(&mut async_cx, |state, cx| { + state.process_events(cx); + true + }) + .unwrap_or(false); + if !should_continue { + break; + } + } + } + }); + let scrollbar = cx.new(ScrollbarState::new); + // The subscription is held for as long as the state lives: dropping it would + // silently disconnect the bar from the viewport it moves. + let scrollbar_subscription = cx.subscribe(&scrollbar, |state, _, event, cx| { + let ScrollbarEvent::Scrolled { offset } = *event; + state.scroll_to_scrollbar_offset(offset, cx); + }); + let scroll_state = session.scroll_state(); + Self { + session, + focus: cx.focus_handle(), + size, + metrics: TerminalMetrics { + cell_width: px(1.0), + cell_height: px(1.0), + font_size: px(FONT_SIZE), + }, + status: None, + ended: None, + preedit: None, + scroll_state, + scroll_remainder: 0.0, + scrollbar, + _scrollbar_subscription: scrollbar_subscription, + _event_task: event_task, + } + } + + /// Returns the terminal backend profile. + pub fn profile(&self) -> chitin_terminal::TerminalProfile { + self.session.profile() + } + + /// Returns the focus handle owned by the terminal surface. + pub fn focus_handle(&self) -> &FocusHandle { + &self.focus + } + + /// Returns the input method composition currently painted over the grid. + fn preedit(&self) -> Option<&TerminalPreedit> { + self.preedit.as_ref() + } + + /// Writes already encoded bytes to the active terminal backend. + pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalSessionError> { + self.session.write(bytes) + } + + fn process_events(&mut self, cx: &mut Context) { + // Read before the events are drained: a child program entering or leaving the + // alternate screen changes what the wheel means, and that is observed here rather + // than at the next wheel event. The read is a few counters under the terminal lock. + self.scroll_state = self.session.scroll_state(); + let events = self.session.drain_events(); + if events.is_empty() { + return; + } + for event in events { + // A backend that has already stopped has nothing further to report: what arrives + // after its exit is a consequence of the shutdown, and must not overwrite the + // exit status the surface is showing. + if self.ended.is_some() { + break; + } + match event { + TerminalEvent::Exited(code) => self.end(code, cx), + TerminalEvent::Error(error) => self.status = Some(error), + TerminalEvent::Render | TerminalEvent::Title(_) | TerminalEvent::Bell => {} + } + } + cx.notify(); + } + + /// Records a backend that stopped and hands its exit code to the host. + /// + /// The session itself stays in place. Whether a stopped backend should also take its + /// session down depends on what else the host has open, so that decision belongs to + /// the host and travels there as [`TerminalEmulatorEvent::Exited`]. + fn end(&mut self, code: u32, cx: &mut Context) { + self.ended = Some(code); + self.status = Some(format!("process exited with code {code}")); + cx.emit(TerminalEmulatorEvent::Exited { code }); + } + + fn resize(&mut self, bounds: Bounds, metrics: TerminalMetrics) { + // Kept so the input method can be pointed at the same cell geometry the grid is painted + // with, which is finer than the whole-pixel cell size reported to the backend. + self.metrics = metrics; + let next = terminal_size_for_bounds(bounds, metrics); + // A stopped backend has no grid left to resize, and the failure it would report for + // one would replace the exit status the surface is showing. + if next == self.size || self.ended.is_some() { + return; + } + match self.session.resize(next) { + Ok(()) => { + self.size = next; + // A resize reflows the grid and can shorten the history the viewport sits in, + // which the backend answers by clamping the position it had. + self.refresh_scroll_state(); + // The cell height the wheel was measured against has changed, so a fraction + // carried over from before it no longer names the same distance. + self.scroll_remainder = 0.0; + } + Err(error) => self.status = Some(error.to_string()), + } + } + + fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + // Scrolling reaches back through whatever a stopped program left on screen, which + // is exactly when its last screen is worth reading, and it writes no bytes for the + // guards below to withhold. A live composition is the one case it waits for: the + // composition is anchored to the row it captured when it started, so moving the + // viewport would paint it over different content. The guard below then swallows + // the keystroke, exactly as the wheel does. + if self.preedit.is_none() + && let Some(scroll) = scroll_keystroke(&event.keystroke) + { + self.scroll_viewport(scroll, cx); + cx.stop_propagation(); + return; + } + // A stopped backend has nowhere to put input, but the surface still swallows the + // keystroke so it cannot reach a window shortcut behind the frozen grid. + if self.ended.is_some() { + cx.stop_propagation(); + return; + } + // A live composition owns every keystroke: Enter commits it, Escape cancels it, + // and the arrow keys pick a candidate. Forwarding those bytes would send them to + // the child program as well, which is what the composition has not done yet. + if self.preedit.is_some() { + cx.stop_propagation(); + return; + } + let Some(bytes) = encode_key(event) else { + return; + }; + self.write_input(&bytes, cx); + cx.stop_propagation(); + } + + /// Delivers encoded input to the active backend and repaints the surface. + fn write_input(&mut self, bytes: &[u8], cx: &mut Context) { + // Committed composition text arrives here too, and a stopped backend would answer + // both with an error rather than with the child program's own echo. + if self.ended.is_some() { + return; + } + // Input is aimed at the live edge. Echoing into a viewport that has been scrolled + // back would put the child program's answer somewhere the user cannot see. + if self.scroll_state.display_offset != 0 { + self.session.scroll(TerminalScroll::Bottom); + self.refresh_scroll_state(); + } + match self.session.write(bytes) { + Ok(()) => cx.emit(TerminalEmulatorEvent::InputWritten), + Err(error) => self.status = Some(error.to_string()), + } + cx.notify(); + } + + /// Applies one wheel event to the viewport, or forwards it to a full-screen program. + fn handle_scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context) { + // A live composition anchors to a row captured when it started, and scrolling + // would paint it over different content. A stopped backend has nothing to scroll. + if self.ended.is_some() || self.preedit.is_some() { + cx.stop_propagation(); + return; + } + + let lines = wheel_lines(&event.delta, self.metrics.cell_height, &mut self.scroll_remainder); + if lines == 0 { + cx.stop_propagation(); + return; + } + // A full-screen program keeps no scrollback of its own, so the wheel means to it + // what the arrow keys mean: the only viewport motion it understands. + if self.scroll_state.alt_screen { + self.write_arrows(lines, cx); + } else { + self.scroll_viewport(TerminalScroll::Lines(lines), cx); + } + cx.stop_propagation(); + } + + /// Forwards wheel motion to a full-screen program as the arrow keys it reads. + fn write_arrows(&mut self, lines: i32, cx: &mut Context) { + let key = if lines > 0 { "up" } else { "down" }; + let Some(bytes) = encode_special_key(key, false) else { + return; + }; + // One arrow per whole line, capped so a trackpad fling cannot flood the child. + for _ in 0..lines.unsigned_abs().min(MAX_WHEEL_LINES_PER_EVENT as u32) { + self.write_input(bytes, cx); + } + } + + /// Moves the viewport and repaints the surface. + fn scroll_viewport(&mut self, scroll: TerminalScroll, cx: &mut Context) { + self.session.scroll(scroll); + self.refresh_scroll_state(); + // The backend reports a viewport motion as a cursor-dirty event and coalesces it, + // so the repaint is asked for here rather than waited for. + cx.notify(); + } + + /// Moves the viewport to the position a scrollbar drag described. + fn scroll_to_scrollbar_offset(&mut self, offset: Pixels, cx: &mut Context) { + let display_offset = + display_offset_for_scrollbar_offset(offset, self.metrics.cell_height, self.scroll_state.history_size); + self.scroll_viewport(TerminalScroll::Offset(display_offset), cx); + } + + /// Reads the viewport position back from the session. + /// + /// The wheel remainder is deliberately left alone: it is the sub-line part of a + /// gesture still in progress, and clearing it here would drop that fraction every + /// time the viewport crossed a whole line. + fn refresh_scroll_state(&mut self) { + self.scroll_state = self.session.scroll_state(); + } +} + +impl EventEmitter for TerminalEmulatorState {} + +/// Routes platform text input, input method composition included, into the session. +/// +/// A terminal owns no editable text buffer: committed text is written to the active +/// backend, and every glyph on the grid arrives back from that backend's own output. +/// The composition is the single piece of text that has to live here, because the +/// child program must not see it until the input method commits it. +impl EntityInputHandler for TerminalEmulatorState { + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + // Nothing to hand back: the grid is the child program's output, not a buffer to read. + None + } + + fn selected_text_range(&mut self, _: bool, _: &mut Window, _: &mut Context) -> Option { + // A terminal holds no selection. Reporting a collapsed range still presents the + // surface as a live text input target, which is what wakes the platform input method. + Some(UTF16Selection { + range: 0..0, + reversed: false, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + self + .preedit + .as_ref() + .map(|preedit| 0..preedit.text.encode_utf16().count()) + } + + fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { + if self.preedit.take().is_some() { + cx.notify(); + } + } + + fn replace_text_in_range(&mut self, _: Option>, text: &str, _: &mut Window, cx: &mut Context) { + // A commit ends the composition and hands the finished text to the child program, + // exactly as if the keystrokes had been typed out one by one. + self.preedit = None; + if text.is_empty() { + cx.notify(); + return; + } + self.write_input(text.as_bytes(), cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + _: Option>, + text: &str, + _: Option>, + window: &mut Window, + cx: &mut Context, + ) { + // An empty composition is how the platform reports a cancelled one. + if text.is_empty() { + if self.preedit.take().is_some() { + cx.notify(); + } + return; + } + // Keep the anchor captured when the composition started, so shell output arriving + // mid-edit cannot drag the composition to another cell. + let anchor = self + .preedit + .as_ref() + .map(|preedit| (preedit.row, preedit.column)) + .or_else(|| self.session.snapshot().cursor) + .unwrap_or((0, 0)); + self.preedit = Some(TerminalPreedit { + text: text.to_string(), + row: anchor.0, + column: anchor.1, + }); + // The platform holds no cursor of its own for this surface, so the candidate window + // stays wherever it was last told until it is handed the composition anchor again. + window.invalidate_character_coordinates(); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + _: Range, + element_bounds: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + // Places the input method's candidate window over the composition anchor instead of + // leaving it pinned to the corner of the window. + let (row, column) = self + .preedit + .as_ref() + .map(|preedit| (preedit.row, preedit.column)) + .or_else(|| self.session.snapshot().cursor)?; + let cell_width = self.metrics.cell_width; + let cell_height = self.metrics.cell_height; + Some(Bounds::new( + point( + element_bounds.left() + cell_width * column as f32, + element_bounds.top() + cell_height * row as f32, + ), + size(cell_width, cell_height), + )) + } + + fn character_index_for_point(&mut self, _: Point, _: &mut Window, _: &mut Context) -> Option { + // The terminal hit-tests its own pointer input, so there is no index to report. + None + } + + fn accepts_text_input(&self, _: &mut Window, _: &mut Context) -> bool { + true + } +} + +/// Interactive GPUI surface for either a PTY or in-process terminal backend. +#[derive(IntoElement)] +pub struct TerminalEmulator { + state: Entity, + theme: UIThemes, + font_family: SharedString, +} + +impl TerminalEmulator { + /// Creates a terminal emulator bound to persistent session state. + pub fn new(state: Entity) -> Self { + Self { + state, + theme: builtins::dark(), + font_family: "monospace".into(), + } + } + + /// Sets the semantic UI theme. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Sets the upright monospace font used by the character grid. + pub fn font_family(mut self, font_family: impl Into) -> Self { + self.font_family = font_family.into(); + self + } +} + +impl RenderOnce for TerminalEmulator { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let metrics = measure_terminal_metrics(window, self.font_family.clone()); + let status_font = self.font_family.clone(); + let status = self.state.read(cx).status.clone(); + let preedit = self.state.read(cx).preedit().cloned(); + let mut snapshot = self.state.read(cx).session.snapshot(); + // A stopped backend leaves its last frame on the grid, so the cursor is what + // separates a session that has ended from one that is merely waiting. + if self.state.read(cx).ended.is_some() { + snapshot.cursor = None; + } + let focus = self.state.read(cx).focus.clone(); + let scrollbar = self.state.read(cx).scrollbar.clone(); + let scrollbar_metrics = scrollbar_metrics(self.state.read(cx).scroll_state, metrics.cell_height); + let state_for_mouse = self.state.clone(); + let state_for_keys = self.state.clone(); + let state_for_layout = self.state.clone(); + let state_for_scroll = self.state.clone(); + let theme = self.theme; + let rows = (0..snapshot.rows).map(move |row| { + let cells = build_row_runs(&snapshot, row) + .into_iter() + .map(move |run| render_run(run, metrics, theme)); + let grid_row = div().flex().h(metrics.cell_height).whitespace_nowrap().children(cells); + // The composition belongs to exactly one row, and only that row becomes a + // containing block for its overlay. + match preedit.as_ref().filter(|preedit| preedit.row == row) { + Some(preedit) => grid_row.relative().child(render_preedit(preedit, metrics, theme)), + None => grid_row, + } + }); + + let grid = div() + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .track_focus(&focus) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + let focus = state_for_mouse.read(cx).focus.clone(); + window.focus(&focus, cx); + }) + .on_key_down(move |event, _, cx| { + state_for_keys.update(cx, |state, cx| state.handle_key(event, cx)); + }) + .on_children_prepainted(move |bounds, _, cx| { + let Some(bounds) = bounds.first().copied() else { + return; + }; + state_for_layout.update(cx, |state, _| state.resize(bounds, metrics)); + }) + .bg(theme.background.primary) + .font(terminal_font(self.font_family)) + .text_size(metrics.font_size) + .line_height(metrics.cell_height) + .child(div().flex().flex_col().size_full().children(rows)); + + // The grid keeps the whole surface to itself until the backend has something to + // report, so the input method anchor and the painted grid stay on the same bounds. + // The wheel listener sits on this outer surface rather than on the grid so that it + // also covers the scrollbar, whose lane is a sibling of the surface below. + div() + .flex() + .flex_col() + .size_full() + .on_scroll_wheel(move |event, _, cx| { + state_for_scroll.update(cx, |state, cx| state.handle_scroll_wheel(event, cx)); + }) + .child( + div() + .relative() + .flex_1() + .min_h_0() + .child(TerminalSurface { + content: grid.into_any_element(), + state: self.state, + }) + // The bar is an overlay rather than a column of its own, so gaining and + // losing scrollback cannot change the width the backend is laid out for. + .child(Scrollbar::new(scrollbar).metrics(scrollbar_metrics).theme(theme)), + ) + .when_some(status, move |surface, status| { + surface.child(render_status(&status, metrics, theme, status_font)) + }) + } +} + +/// Paints a backend's final or failed state below the grid. +fn render_status( + status: &str, + metrics: TerminalMetrics, + theme: UIThemes, + font_family: SharedString, +) -> impl IntoElement { + div() + .flex_none() + .w_full() + .h(metrics.cell_height) + .px(metrics.cell_width) + .border_t_1() + .border_color(theme.border.muted) + .whitespace_nowrap() + .overflow_hidden() + .font(terminal_font(font_family)) + .text_size(metrics.font_size) + .text_color(theme.text.secondary) + .child(status.to_owned()) +} + +/// Wraps the terminal grid so the surface can register itself as a text input target. +/// +/// GPUI enables the platform input method only for the element that calls +/// [`Window::handle_input`], and only during paint. A `Div` has no paint-phase hook, so +/// this element delegates the entire layout, prepaint, and paint pass to the grid and +/// then registers the handler against the grid's own bounds. +struct TerminalSurface { + content: AnyElement, + state: Entity, +} + +impl IntoElement for TerminalSurface { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for TerminalSurface { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + Element::request_layout(&mut self.content, id, inspector_id, window, cx) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + Element::prepaint(&mut self.content, id, inspector_id, bounds, request_layout, window, cx) + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + Element::paint( + &mut self.content, + id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ); + // Registering a handler is also what turns the platform input method on; GPUI + // disables it for any frame that registers none. Losing focus therefore cancels a + // composition through the platform's own `unmark_text` path. + let focus = self.state.read(cx).focus.clone(); + window.handle_input(&focus, ElementInputHandler::new(bounds, self.state.clone()), cx); + } +} + +/// Paints the active input method composition over the grid at its anchor cell. +/// +/// The composition is not part of the grid, so it covers the cells underneath instead of +/// replacing them. It disappears the moment the input method commits, at which point the +/// child program echoes the committed glyphs itself. +fn render_preedit(preedit: &TerminalPreedit, metrics: TerminalMetrics, theme: UIThemes) -> Div { + div() + .absolute() + .top_0() + .left(metrics.cell_width * preedit.column as f32) + .h(metrics.cell_height) + .whitespace_nowrap() + .bg(theme.background.primary) + .text_color(theme.text.primary) + .underline() + .child(preedit.text.clone()) +} + +#[derive(Clone, Copy)] +struct TerminalMetrics { + cell_width: Pixels, + cell_height: Pixels, + font_size: Pixels, +} + +fn measure_terminal_metrics(window: &mut Window, family: SharedString) -> TerminalMetrics { + let font_size = px(FONT_SIZE); + let regular_width = measure_cell_width(window, family.clone(), font_size, FontWeight::NORMAL); + let bold_width = measure_cell_width(window, family, font_size, FontWeight::BOLD); + TerminalMetrics { + cell_width: regular_width.max(bold_width).max(px(1.0)), + cell_height: px((FONT_SIZE * LINE_HEIGHT_MULTIPLIER).round()).max(px(1.0)), + font_size, + } +} + +fn measure_cell_width(window: &mut Window, family: SharedString, font_size: Pixels, weight: FontWeight) -> Pixels { + let mut terminal_font = terminal_font(family); + terminal_font.weight = weight; + window + .text_system() + .shape_line( + SharedString::new_static("M"), + font_size, + &[TextRun { + len: 1, + font: terminal_font, + color: Hsla::default().into(), + ..TextRun::default() + }], + None, + ) + .width +} + +fn terminal_size_for_bounds(bounds: Bounds, metrics: TerminalMetrics) -> TerminalSize { + let cell_width = f32::from(metrics.cell_width); + let cell_height = f32::from(metrics.cell_height); + TerminalSize::new( + (f32::from(bounds.size.width) / cell_width).floor().max(1.0) as usize, + (f32::from(bounds.size.height) / cell_height).floor().max(1.0) as usize, + cell_width.ceil() as u16, + cell_height.ceil() as u16, + ) +} + +fn terminal_font(family: SharedString) -> Font { + let mut terminal_font = font(family); + terminal_font.features = FontFeatures(Arc::new(vec![ + ("calt".into(), 0), + ("liga".into(), 0), + ("clig".into(), 0), + ])); + terminal_font.fallbacks = Some(FontFallbacks::from_fonts(vec![ + "Symbols Nerd Font".into(), + "Noto Color Emoji".into(), + "Segoe UI Emoji".into(), + "Apple Color Emoji".into(), + "Noto Sans Symbols 2".into(), + ])); + terminal_font +} + +struct TerminalRun { + text: String, + columns: usize, + foreground: TerminalColor, + background: TerminalColor, + attributes: TerminalCellAttributes, + /// Whether the run holds a single glyph that is painted across two grid columns. + wide: bool, + cursor: bool, +} + +impl TerminalRun { + fn new(cell: &TerminalCell, cursor: bool) -> Self { + let mut run = Self { + text: String::new(), + columns: 0, + foreground: cell.foreground, + background: cell.background, + attributes: run_attributes(cell.attributes), + wide: cell.attributes.wide, + cursor, + }; + run.push(cell); + run + } + + fn matches(&self, cell: &TerminalCell, cursor: bool) -> bool { + self.foreground == cell.foreground + && self.background == cell.background + && self.attributes == run_attributes(cell.attributes) + && self.wide == cell.attributes.wide + && self.cursor == cursor + } + + fn push(&mut self, cell: &TerminalCell) { + // A wide cell is the only cell of its run, and it is the one that accounts for both of + // the columns the glyph covers. + self.columns += if cell.attributes.wide { 2 } else { 1 }; + if cell.attributes.hidden { + self.text.push(' '); + return; + } + self.text.push(cell.character); + self.text.extend(cell.combining_characters.iter()); + } +} + +fn build_row_runs(snapshot: &TerminalSnapshot, row: usize) -> Vec { + let mut runs: Vec = Vec::new(); + let visible_columns = (0..snapshot.columns) + .rfind(|&column| { + snapshot.cursor == Some((row, column)) || snapshot.cell(row, column).is_some_and(|cell| !is_default_blank(cell)) + }) + .map_or(0, |column| column + 1); + for column in 0..visible_columns { + let Some(cell) = snapshot.cell(row, column) else { + continue; + }; + // The cell that continues a wide glyph is a placeholder: the wide glyph's own run + // already covers its column, and painting it as a space would cover the right half of + // the glyph that precedes it. + if cell.attributes.wide_spacer { + continue; + } + let cursor = snapshot.cursor == Some((row, column)); + // A wide glyph is placed by the grid rather than by the text flow, because its advance + // is not exactly two cell widths. Keeping it in a run of its own leaves every following + // run starting exactly where the grid says the next column starts. + let extended = runs.last_mut().filter(|run| !run.wide && run.matches(cell, cursor)); + match extended { + Some(run) => run.push(cell), + None => runs.push(TerminalRun::new(cell, cursor)), + } + } + runs +} + +fn is_default_blank(cell: &TerminalCell) -> bool { + cell.character == ' ' + && cell.combining_characters.is_empty() + && cell.foreground == TerminalColor::Named(TerminalNamedColor::Foreground) + && cell.background == TerminalColor::Named(TerminalNamedColor::Background) + && upright_attributes(cell.attributes) == TerminalCellAttributes::default() +} + +fn upright_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAttributes { + attributes.italic = false; + attributes +} + +/// Attributes that decide where one painted run ends and the next one begins. +/// +/// Slant is dropped because the grid font ships upright only, and the wide-continuation +/// marker is dropped because it describes the cell's role in the grid rather than how the +/// cell looks. +fn run_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAttributes { + attributes.italic = false; + attributes.wide_spacer = false; + attributes +} + +fn render_run(run: TerminalRun, metrics: TerminalMetrics, theme: UIThemes) -> impl IntoElement { + let (mut foreground, mut background) = ( + resolve_color(run.foreground, theme), + resolve_color(run.background, theme), + ); + if run.attributes.inverse { + std::mem::swap(&mut foreground, &mut background); + } + if run.attributes.dim { + foreground = foreground.opacity(0.62); + } + if run.cursor { + std::mem::swap(&mut foreground, &mut background); + } + div() + .flex_none() + .w(metrics.cell_width * run.columns as f32) + .h(metrics.cell_height) + .whitespace_nowrap() + .bg(background) + .text_color(foreground) + .when(run.attributes.bold, |element| element.font_weight(FontWeight::BOLD)) + .when(run.attributes.underline, |element| element.underline()) + .child(run.text) +} + +fn resolve_color(color: TerminalColor, theme: UIThemes) -> Hsla { + match color { + TerminalColor::Rgb { red, green, blue } => { + rgb((u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue)).into() + } + TerminalColor::Indexed(index) => indexed_color(index), + TerminalColor::Named(TerminalNamedColor::Foreground) => theme.text.primary.into(), + TerminalColor::Named(TerminalNamedColor::Background) => theme.background.primary.into(), + TerminalColor::Named(named) => named_color(named), + } +} + +fn named_color(color: TerminalNamedColor) -> Hsla { + let value = match color { + TerminalNamedColor::Black | TerminalNamedColor::DimBlack => 0x1f2329, + TerminalNamedColor::Red | TerminalNamedColor::DimRed => 0xff5f56, + TerminalNamedColor::Green | TerminalNamedColor::DimGreen => 0x63d17c, + TerminalNamedColor::Yellow | TerminalNamedColor::DimYellow => 0xe5c07b, + TerminalNamedColor::Blue | TerminalNamedColor::DimBlue => 0x61afef, + TerminalNamedColor::Magenta | TerminalNamedColor::DimMagenta => 0xc678dd, + TerminalNamedColor::Cyan | TerminalNamedColor::DimCyan => 0x56b6c2, + TerminalNamedColor::White | TerminalNamedColor::DimWhite => 0xabb2bf, + TerminalNamedColor::BrightBlack => 0x5c6370, + TerminalNamedColor::BrightRed => 0xff7b72, + TerminalNamedColor::BrightGreen => 0x7ee787, + TerminalNamedColor::BrightYellow => 0xf2cc60, + TerminalNamedColor::BrightBlue => 0x79c0ff, + TerminalNamedColor::BrightMagenta => 0xd2a8ff, + TerminalNamedColor::BrightCyan => 0x76e3ea, + TerminalNamedColor::BrightWhite | TerminalNamedColor::BrightForeground => 0xffffff, + TerminalNamedColor::Foreground | TerminalNamedColor::Cursor => 0xd0d7de, + TerminalNamedColor::Background => 0x101216, + TerminalNamedColor::DimForeground => 0x8b949e, + }; + rgb(value).into() +} + +fn indexed_color(index: u8) -> Hsla { + const ANSI: [u32; 16] = [ + 0x1f2329, 0xff5f56, 0x63d17c, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xabb2bf, 0x5c6370, 0xff7b72, 0x7ee787, + 0xf2cc60, 0x79c0ff, 0xd2a8ff, 0x76e3ea, 0xffffff, + ]; + let value = match index { + 0..=15 => ANSI[index as usize], + 16..=231 => { + let cube = index - 16; + let component = |value: u8| if value == 0 { 0 } else { 55 + value * 40 }; + let red = component(cube / 36); + let green = component((cube % 36) / 6); + let blue = component(cube % 6); + (u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue) + } + 232..=255 => { + let gray = 8 + (index - 232) * 10; + (u32::from(gray) << 16) | (u32::from(gray) << 8) | u32::from(gray) + } + }; + rgb(value).into() +} + +/// Returns the bytes a terminal program expects for a named special key. +/// +/// The table is shared by key encoding and by the wheel, which forwards motion to a +/// full-screen program as arrow keys, so the two cannot disagree about what an arrow is. +fn encode_special_key(key: &str, shift: bool) -> Option<&'static [u8]> { + match key { + "enter" => Some(b"\r"), + "backspace" => Some(b"\x7f"), + "tab" if shift => Some(b"\x1b[Z"), + "tab" => Some(b"\t"), + "escape" => Some(b"\x1b"), + "up" => Some(b"\x1b[A"), + "down" => Some(b"\x1b[B"), + "right" => Some(b"\x1b[C"), + "left" => Some(b"\x1b[D"), + "home" => Some(b"\x1b[H"), + "end" => Some(b"\x1b[F"), + "pageup" => Some(b"\x1b[5~"), + "pagedown" => Some(b"\x1b[6~"), + "delete" => Some(b"\x1b[3~"), + "insert" => Some(b"\x1b[2~"), + _ => None, + } +} + +/// Returns the viewport motion a keystroke asks for. +/// +/// Only the shifted forms scroll. Unmodified keys have to keep reaching the child +/// program as the bytes it expects, and the combinations that select text elsewhere +/// are left alone rather than guessed at. +fn scroll_keystroke(keystroke: &Keystroke) -> Option { + let modifiers = keystroke.modifiers; + if !modifiers.shift || modifiers.control || modifiers.alt || modifiers.platform { + return None; + } + + match keystroke.key.as_str() { + "pageup" => Some(TerminalScroll::PageUp), + "pagedown" => Some(TerminalScroll::PageDown), + "home" => Some(TerminalScroll::Top), + "end" => Some(TerminalScroll::Bottom), + _ => None, + } +} + +/// Converts one wheel event into whole lines of viewport motion. +/// +/// # Parameters +/// +/// * `delta` is the platform's scroll delta for this event. +/// * `cell_height` measures a pixel delta in lines. +/// * `remainder` carries the sub-line part of earlier events. A precision trackpad +/// reports fractions of a line per event, and discarding each one separately would +/// make a slow gesture scroll nothing at all. +/// +/// # Returns +/// +/// Whole lines to scroll, positive toward older output, capped at +/// [`MAX_WHEEL_LINES_PER_EVENT`]. Zero when this event only accumulated a fraction. +fn wheel_lines(delta: &ScrollDelta, cell_height: Pixels, remainder: &mut f32) -> i32 { + let lines = match delta { + ScrollDelta::Lines(lines) => lines.y, + ScrollDelta::Pixels(pixels) => f32::from(pixels.y) / f32::from(cell_height.max(px(1.0))), + }; + + *remainder += lines; + let whole = remainder.trunc(); + *remainder -= whole; + (whole as i32).clamp(-MAX_WHEEL_LINES_PER_EVENT, MAX_WHEEL_LINES_PER_EVENT) +} + +/// Describes a viewport position in scrollbar geometry. +/// +/// The bar puts the oldest output at the top of the content and the live edge at the +/// bottom, and it measures downward from zero the way GPUI scroll offsets do. +fn scrollbar_metrics(scroll: TerminalScrollState, cell_height: Pixels) -> ScrollbarMetrics { + let viewport_height = cell_height * scroll.screen_lines as f32; + let content_height = cell_height * (scroll.screen_lines + scroll.history_size) as f32; + let offset = -(cell_height * scroll.lines_above() as f32); + + ScrollbarMetrics::new(content_height, viewport_height, offset) +} + +/// Converts a position on the scrollbar back into a viewport display offset. +/// +/// # Parameters +/// +/// * `offset` is the bar's distance from the top of the content, negative downward. +/// * `cell_height` measures that distance in lines. +/// * `history_size` bounds the result to the lines that are actually retained. +/// +/// # Returns +/// +/// The number of lines the viewport should sit above the live edge. +fn display_offset_for_scrollbar_offset(offset: Pixels, cell_height: Pixels, history_size: usize) -> usize { + let lines = f32::from(offset) / f32::from(cell_height.max(px(1.0))); + let display_offset = history_size as f32 + lines.round(); + + display_offset.clamp(0.0, history_size as f32) as usize +} + +fn encode_key(event: &KeyDownEvent) -> Option> { + let key = event.keystroke.key.as_str(); + let modifiers = event.keystroke.modifiers; + if let Some(special) = encode_special_key(key, modifiers.shift) { + return Some(special.to_vec()); + } + if modifiers.platform { + return None; + } + let text = event.keystroke.key_char.as_deref()?; + let mut bytes = if modifiers.control && text.len() == 1 { + let byte = text.as_bytes()[0].to_ascii_uppercase(); + if byte.is_ascii_uppercase() { + vec![byte & 0x1f] + } else { + return None; + } + } else { + text.as_bytes().to_vec() + }; + if modifiers.alt { + bytes.insert(0, 0x1b); + } + Some(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn narrow(character: char) -> TerminalCell { + TerminalCell { + character, + ..TerminalCell::default() + } + } + + fn wide(character: char) -> TerminalCell { + TerminalCell { + character, + attributes: TerminalCellAttributes { + wide: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + } + } + + fn continuation() -> TerminalCell { + TerminalCell { + attributes: TerminalCellAttributes { + wide_spacer: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + } + } + + fn hidden(character: char) -> TerminalCell { + TerminalCell { + character, + attributes: TerminalCellAttributes { + hidden: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + } + } + + fn row_of(cells: Vec) -> TerminalSnapshot { + TerminalSnapshot { + columns: cells.len(), + rows: 1, + cells, + cursor: None, + display_offset: 0, + history_size: 0, + } + } + + #[test] + fn wide_character_should_span_two_columns_and_hide_its_continuation() { + let runs = build_row_runs(&row_of(vec![wide('中'), continuation()]), 0); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].columns, 2); + assert_eq!(runs[0].text, "中"); + } + + #[test] + fn text_after_a_wide_character_should_start_at_its_own_grid_column() { + let runs = build_row_runs(&row_of(vec![wide('中'), continuation(), narrow('a'), narrow('b')]), 0); + + assert_eq!(runs.len(), 2); + assert_eq!((runs[0].columns, runs[0].text.as_str()), (2, "中")); + assert_eq!((runs[1].columns, runs[1].text.as_str()), (2, "ab")); + } + + #[test] + fn adjacent_wide_characters_should_not_share_a_run() { + let runs = build_row_runs(&row_of(vec![wide('中'), continuation(), wide('文'), continuation()]), 0); + + assert_eq!(runs.len(), 2); + assert!(runs.iter().all(|run| run.wide && run.columns == 2)); + assert_eq!(runs[1].text, "文"); + } + + #[test] + fn hidden_text_should_keep_its_columns_without_revealing_its_glyphs() { + let runs = build_row_runs(&row_of(vec![narrow('a'), hidden('x'), narrow('b')]), 0); + + assert_eq!( + runs.iter().map(|run| run.text.as_str()).collect::>(), + ["a", " ", "b"] + ); + assert_eq!(runs.iter().map(|run| run.columns).sum::(), 3); + } + + /// Builds a viewport position for the scrollbar helper tests. + fn scroll_state(history: usize, offset: usize, lines: usize) -> TerminalScrollState { + TerminalScrollState { + history_size: history, + display_offset: offset, + screen_lines: lines, + alt_screen: false, + } + } + + #[test] + fn a_wheel_tick_toward_the_top_should_reveal_older_lines() { + let mut remainder = 0.0; + + let lines = wheel_lines(&ScrollDelta::Lines(point(0.0, 1.0)), px(20.0), &mut remainder); + + assert_eq!(lines, 1); + } + + #[test] + fn a_wheel_tick_toward_the_bottom_should_move_toward_the_live_edge() { + let mut remainder = 0.0; + + let lines = wheel_lines(&ScrollDelta::Lines(point(0.0, -1.0)), px(20.0), &mut remainder); + + assert_eq!(lines, -1); + } + + #[test] + fn pixel_scrolling_should_accumulate_until_a_whole_line_is_reached() { + let mut remainder = 0.0; + // Quarters rather than thirds: a third of a line is not exactly representable, + // and three of them sum to just under one. + let quarter = ScrollDelta::Pixels(point(px(0.0), px(5.0))); + + let steps = (0..4) + .map(|_| wheel_lines(&quarter, px(20.0), &mut remainder)) + .collect::>(); + + // Four quarters of a line must scroll once between them, not four times zero. + assert_eq!(steps, [0, 0, 0, 1]); + } + + #[test] + fn a_pixel_delta_smaller_than_a_line_should_not_scroll_yet() { + let mut remainder = 0.0; + + let lines = wheel_lines(&ScrollDelta::Pixels(point(px(0.0), px(2.0))), px(20.0), &mut remainder); + + assert_eq!(lines, 0); + assert!( + remainder > 0.0, + "the unused part of the delta should carry to the next event" + ); + } + + #[test] + fn a_wheel_event_should_not_scroll_more_than_the_per_event_cap() { + let mut remainder = 0.0; + + let lines = wheel_lines( + &ScrollDelta::Pixels(point(px(0.0), px(10_000.0))), + px(20.0), + &mut remainder, + ); + + assert_eq!(lines, MAX_WHEEL_LINES_PER_EVENT); + } + + #[test] + fn a_scroll_keystroke_should_require_shift_and_no_other_modifier() { + let plain = Keystroke { + key: "pageup".into(), + ..Keystroke::default() + }; + let shifted = Keystroke { + modifiers: gpui::Modifiers { + shift: true, + ..gpui::Modifiers::default() + }, + key: "pageup".into(), + ..Keystroke::default() + }; + + assert_eq!(scroll_keystroke(&plain), None); + assert_eq!(scroll_keystroke(&shifted), Some(TerminalScroll::PageUp)); + } + + #[test] + fn a_scroll_keystroke_should_map_the_viewport_motions() { + let keystroke = |key: &'static str| Keystroke { + modifiers: gpui::Modifiers { + shift: true, + ..gpui::Modifiers::default() + }, + key: key.into(), + ..Keystroke::default() + }; + + assert_eq!(scroll_keystroke(&keystroke("pageup")), Some(TerminalScroll::PageUp)); + assert_eq!(scroll_keystroke(&keystroke("pagedown")), Some(TerminalScroll::PageDown)); + assert_eq!(scroll_keystroke(&keystroke("home")), Some(TerminalScroll::Top)); + assert_eq!(scroll_keystroke(&keystroke("end")), Some(TerminalScroll::Bottom)); + assert_eq!(scroll_keystroke(&keystroke("a")), None); + } + + #[test] + fn an_unmodified_page_key_should_still_reach_the_child_program() { + // The scroll branch runs before key encoding, so it must decline the keystrokes + // that the program is waiting for. + let keystroke = Keystroke { + key: "pageup".into(), + ..Keystroke::default() + }; + + assert_eq!(scroll_keystroke(&keystroke), None); + assert_eq!(encode_special_key("pageup", false), Some(b"\x1b[5~".as_slice())); + } + + #[test] + fn a_wheel_arrow_should_match_the_arrow_key_encoding() { + // The wheel forwards motion to a full-screen program as arrow keys, so the bytes + // it sends and the bytes the arrow keys send have to be the same ones. + assert_eq!(encode_special_key("up", false), Some(b"\x1b[A".as_slice())); + assert_eq!(encode_special_key("down", false), Some(b"\x1b[B".as_slice())); + } + + #[test] + fn a_shifted_tab_should_still_encode_as_a_back_tab() { + assert_eq!(encode_special_key("tab", true), Some(b"\x1b[Z".as_slice())); + assert_eq!(encode_special_key("tab", false), Some(b"\t".as_slice())); + } + + #[test] + fn the_scrollbar_should_measure_from_the_oldest_line_to_the_live_edge() { + let metrics = scrollbar_metrics(scroll_state(100, 0, 24), px(20.0)); + + // At the live edge the viewport sits at the bottom of the content. + assert_eq!(metrics.viewport_height, px(480.0)); + assert_eq!(metrics.content_height, px(2480.0)); + assert_eq!(metrics.offset, px(-2000.0)); + assert_eq!(metrics.thumb_top_fraction(), 1.0 - metrics.thumb_extent_fraction()); + } + + #[test] + fn the_scrollbar_should_sit_at_the_top_when_scrolled_all_the_way_back() { + let metrics = scrollbar_metrics(scroll_state(100, 100, 24), px(20.0)); + + assert_eq!(metrics.offset, Pixels::ZERO); + assert_eq!(metrics.thumb_top_fraction(), 0.0); + } + + #[test] + fn a_scrollbar_offset_should_round_trip_through_the_display_offset() { + for display_offset in [0, 1, 37, 100] { + let metrics = scrollbar_metrics(scroll_state(100, display_offset, 24), px(20.0)); + + assert_eq!( + display_offset_for_scrollbar_offset(metrics.offset, px(20.0), 100), + display_offset, + ); + } + } + + #[test] + fn a_display_offset_should_stay_inside_the_retained_history() { + // The bar measures downward from the oldest line, so an offset above the top + // of the content asks for more history than is retained and lands on the + // oldest line, and one below the bottom lands on the live edge. + assert_eq!(display_offset_for_scrollbar_offset(px(5_000.0), px(20.0), 100), 100); + assert_eq!(display_offset_for_scrollbar_offset(px(-5_000.0), px(20.0), 100), 0); + } +} diff --git a/crates/chitin-ui/src/primitive/terminal/mod.rs b/crates/chitin-ui/src/primitive/terminal/mod.rs new file mode 100644 index 00000000..49b1bebf --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/mod.rs @@ -0,0 +1,14 @@ +//! Monospace terminal content and a tail-following scroll viewport. +//! +//! This primitive has no shell or command semantics. Callers provide styled +//! lines and may attach one interactive tail element, such as a live prompt. + +mod emulator; +mod model; +mod render; +mod state; + +pub use emulator::{TerminalEmulator, TerminalEmulatorEvent, TerminalEmulatorState}; +pub use model::{TerminalLine, TerminalSpan, TerminalTone}; +pub use render::TerminalViewport; +pub use state::TerminalViewportState; diff --git a/crates/chitin-ui/src/primitive/terminal/model.rs b/crates/chitin-ui/src/primitive/terminal/model.rs new file mode 100644 index 00000000..67f7cddd --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/model.rs @@ -0,0 +1,3 @@ +//! Re-exports of the frontend-independent terminal text model. + +pub use chitin_terminal::{TerminalLine, TerminalSpan, TerminalTone}; diff --git a/crates/chitin-ui/src/primitive/terminal/render.rs b/crates/chitin-ui/src/primitive/terminal/render.rs new file mode 100644 index 00000000..cce1b964 --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/render.rs @@ -0,0 +1,108 @@ +//! Rendering for terminal lines and their scroll viewport. + +use gpui::{ + AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, + Window, div, prelude::*, +}; + +use super::{TerminalLine, TerminalViewportState}; +use crate::themes::{UIThemes, builtins}; + +/// Scrollable monospace output surface with an optional interactive tail. +#[derive(IntoElement)] +pub struct TerminalViewport { + state: Entity, + lines: Vec, + tail: Option, + theme: UIThemes, + font_family: SharedString, +} + +impl TerminalViewport { + /// Creates an empty terminal viewport bound to persistent scroll state. + pub fn new(state: Entity) -> Self { + Self { + state, + lines: Vec::new(), + tail: None, + theme: builtins::dark(), + font_family: "monospace".into(), + } + } + + /// Replaces the ordered terminal lines. + pub fn lines(mut self, lines: impl IntoIterator) -> Self { + self.lines = lines.into_iter().flat_map(TerminalLine::into_rows).collect(); + self + } + + /// Sets an interactive element rendered after all immutable lines. + pub fn tail(mut self, tail: impl IntoElement) -> Self { + self.tail = Some(tail.into_any_element()); + self + } + + /// Sets the semantic UI theme. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Sets the monospace font family used by all viewport content. + pub fn font_family(mut self, font_family: impl Into) -> Self { + self.font_family = font_family.into(); + self + } +} + +impl RenderOnce for TerminalViewport { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let scroll_handle = self.state.read(cx).scroll_handle().clone(); + let focus_handle = self.state.read(cx).focus_handle().clone(); + let state_for_mouse = self.state.clone(); + let theme = self.theme; + + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&scroll_handle) + .track_focus(&focus_handle) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + let focus = state_for_mouse.read(cx).focus_handle().clone(); + window.focus(&focus, cx); + }) + .px_3() + .py_2() + .gap_1() + .font_family(self.font_family) + .text_sm() + .children(self.lines.into_iter().map(|line| render_line(line, theme))) + .when_some(self.tail, |viewport, tail| viewport.child(tail)) + } +} + +/// Renders one soft-wrapping terminal line. +fn render_line(line: TerminalLine, theme: UIThemes) -> impl IntoElement { + div().flex().flex_wrap().min_h(gpui::px(18.0)).children( + line + .spans() + .iter() + .map(|span| div().text_color(tone_color(span.tone, theme)).child(span.text.clone())), + ) +} + +/// Resolves a frontend-independent terminal tone through the active UI theme. +fn tone_color(tone: super::TerminalTone, theme: UIThemes) -> gpui::Rgba { + match tone { + super::TerminalTone::Primary => theme.text.primary, + super::TerminalTone::Secondary => theme.text.secondary, + super::TerminalTone::Accent => theme.accent.primary, + super::TerminalTone::Success => theme.text.success, + super::TerminalTone::Warning => theme.text.warning, + super::TerminalTone::Error => theme.text.error, + super::TerminalTone::Info => theme.text.info, + } +} diff --git a/crates/chitin-ui/src/primitive/terminal/state.rs b/crates/chitin-ui/src/primitive/terminal/state.rs new file mode 100644 index 00000000..3cb92fdd --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/state.rs @@ -0,0 +1,48 @@ +//! Persistent terminal viewport scroll state. + +use gpui::{Context, FocusHandle, ScrollHandle}; + +/// Persistent scrolling behavior for a terminal viewport. +pub struct TerminalViewportState { + scroll_handle: ScrollHandle, + focus_handle: FocusHandle, + follow_tail: bool, +} + +impl TerminalViewportState { + /// Creates a viewport that follows new output. + pub fn new(cx: &mut Context) -> Self { + Self { + scroll_handle: ScrollHandle::new(), + focus_handle: cx.focus_handle(), + follow_tail: true, + } + } + + /// Returns the GPUI scroll handle used by the viewport. + pub fn scroll_handle(&self) -> &ScrollHandle { + &self.scroll_handle + } + + /// Returns the focus handle used while a command is running. + pub fn focus_handle(&self) -> &FocusHandle { + &self.focus_handle + } + + /// Returns whether new output should remain visible. + pub const fn follows_tail(&self) -> bool { + self.follow_tail + } + + /// Enables or disables automatic tail following. + pub fn set_follow_tail(&mut self, follow_tail: bool) { + self.follow_tail = follow_tail; + } + + /// Scrolls to the newest line when tail following is enabled. + pub fn reveal_tail(&self) { + if self.follow_tail { + self.scroll_handle.scroll_to_bottom(); + } + } +} diff --git a/justfile b/justfile index c19e7693..15476e14 100644 --- a/justfile +++ b/justfile @@ -47,6 +47,10 @@ generate-mmcif-schema: desktop path=".": cargo run -p chitin-desktop -- "{{path}}" +# Launch the native PTY and VT-emulation integration example. +terminal-debug path=".": + cargo run -p chitin-desktop --example terminal-debug -- "{{path}}" + # Rebuild the browser WASM package before starting Vite's development server. browser-dev: pnpm --dir browser wasm:build