diff --git a/CHANGELOG.md b/CHANGELOG.md index 9508cc0..791f9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2026-05-13 + +### Added + +- `filter-constant` command to hide highly conserved columns by threshold - ignores gaps and unknown symbols in DNA and protein alignments. +- `reload-as-protein` command and `Shift+T` shortcut to toggle a full protein-translated alignment view alongside the existing quick translation overlay (`t`). +- Keyboard shortcuts for translation frame selection: `Alt+1`, `Alt+2`, and `Alt+3`. +- Experimental GFF3 annotation support via `load-gff`, including: + - a global feature minimap + - a local feature track above the alignment + - hoverable feature details in the `Feature Info` pane + - mouse drag panning in the GFF pane + - `jump-feature` command to jump to named features from a loaded GFF file + - GFF annotations follow the current coordinate mode: + - in quick translation they remain in nucleotide space + - in reloaded protein mode they are projected into protein columns using the active reading frame + - when columns are filtered, features collapse to the remaining visible columns and may disappear entirely if fully filtered out + +### Changed + +- Translation handling is now split more cleanly between quick overlay translation and full protein reload, with reading frame changes updating both modes correctly. +- Sequence type detection is now deterministic. +- Significant under-the-hood performance improvements in `libmsa`, including faster translation. +- Refactored the UI into panes and layers to simplify, and migrated them so each component implements the ratatui widget trait. +- Some UI changes to make the interface a bit sleeker - dropping obvious titles in panes etc. + +### Fixed + +- Mouse selection highlighting in the `terminal-default` theme using background colour making selection unusable. +- Ruler rendering bugs that could happen with filtered columns and translated views. + ## [0.8.0] - 2026-02-26 ### Added diff --git a/Cargo.lock b/Cargo.lock index cc7702d..8f27fdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,7 +170,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", ] [[package]] @@ -179,6 +179,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -200,6 +209,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -736,6 +755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -1394,6 +1414,63 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.181" @@ -1422,13 +1499,23 @@ dependencies = [ [[package]] name = "libmsa" -version = "0.1.0" +version = "0.2.0" dependencies = [ "rand 0.9.2", + "rayon", "regex", "thiserror 2.0.18", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -1615,6 +1702,54 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "noodles-bgzf" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37290f565045fd2775b549e62dffca7e1afadc70d8d5a3a2ef19609eb3d8193b" +dependencies = [ + "bytes", + "crossbeam-channel", + "flate2", +] + +[[package]] +name = "noodles-core" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e1e8a419dbba0e4000b0e60830b124138c7f2277ad556463506f1a81d32d17" +dependencies = [ + "bstr", +] + +[[package]] +name = "noodles-csi" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c846d8128bd80b18d891b13cb9e0bc1d364429d6ab3ec3c83939f3d32ba105" +dependencies = [ + "bit-vec 0.9.1", + "bstr", + "indexmap", + "noodles-bgzf", + "noodles-core", +] + +[[package]] +name = "noodles-gff" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f76943d61e3b78feb7a248d254590aabd71a3429319fa046992f68a6e9c2e298" +dependencies = [ + "bstr", + "indexmap", + "lexical-core", + "noodles-bgzf", + "noodles-core", + "noodles-csi", + "percent-encoding", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -2471,7 +2606,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "salti" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "clap", @@ -2479,6 +2614,7 @@ dependencies = [ "human-panic", "insta", "libmsa", + "noodles-gff", "nucleo-matcher", "paraseq", "ratatui", @@ -4236,6 +4372,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + [[package]] name = "zmij" version = "1.0.20" diff --git a/README.md b/README.md index 29c3b5c..bca9ace 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,18 @@ [![test](https://github.com/Sam-Sims/salti/actions/workflows/test.yaml/badge.svg)](https://github.com/Sam-Sims/salti/actions/workflows/test.yaml) [![check](https://github.com/Sam-Sims/salti/actions/workflows/check.yaml/badge.svg)](https://github.com/Sam-Sims/salti/actions/workflows/check.yaml) -# salti +

Salti

+ +

+ +

`salti` is a terminal based multiple sequence alignment (MSA) viewer for FASTA files. It is designed for fast interactive browsing primarily on remote servers, and HPC environments, or anytime you dont want to leave the terminal. ## Quick start + If using linux/macOS ```bash @@ -31,7 +36,7 @@ conda install -c bioconda salti - [Usage](#usage) - [Some notes on features](#some-notes-on-features) -## Features +## Feature showcase ### Fast @@ -67,13 +72,6 @@ Press `m` to open the minimap and drag to quickly pan around. ![minimap](assets/minimap.gif) -### Nucleotide and Amino acid support - -`salti` automatically detects whether your alignment is nucleotide (NT) or amino acid (AA), then applies the correct -rendering mode. - -![nt/aa](assets/aant.png) - ### Translation Can translate NT codons to AA on the fly, with support for all 3 frames, although designed for browsing, rather than a @@ -89,6 +87,14 @@ dedicated translation tool. ![viz](assets/viz.gif) +### GFF support + +Support for loading GFF3 annotations with a global minimap + local feature track, and jump straight to features with `jump-feature`. + +See [### GFF support](GFF support) for more detail. + +![gff](assets/gff.gif) + ### Themes `salti` supports multiple colour themes, which can be switched with the `set-theme` command. Available themes so far @@ -195,6 +201,9 @@ I plan to add a help screen in the future for reference in app, but for now here - `Ctrl + Left click` - Select a range of sequences or positions - `Middle click + drag` - Pan. - `m` - Open the minimap +- `t` - Toggle between quick translate of the current view. +- `Shift+t` - Toggle between the original alignment and the reloaded protein alignment. +- `Alt+1|2|3` - Change reading frame for quick/full translation. ### Command palette @@ -211,19 +220,23 @@ Commands: - `jump-position` - Jump to a 1-based alignment position. - `jump-sequence` - Jump to a sequence by name +- `jump-feature` - Jump to a feature if a GFF file has been loaded. - `pin-sequence` - Pin a visible sequence to the top of the alignment view. - `unpin-sequence` - Remove a sequence from the pinned group. - `filter-rows` - Filter rows by their IDs (fasta headers) via regex. - `filter-gaps` - Filter columns by their gap percentage. +- `filter-constant` - Filter columns by their similarity. - `clear-filter` - Clear the active filter. - `set-reference` - Set a reference sequence . - `toggle-translate` - Toggle AA translation. +- `reload-as-protein` - Reloads the entire alignment as a protein alignment. - `set-diff-mode` - Set diff rendering mode (`off`, `reference`, or `consensus`). - `load-alignment` (alias: `load`) - Load an alignment file. +- `load-gff` - Load a GFF3 annotation file. - `set-consensus-method` - Choose `majority` or `majority-non-gap`. - `set-translation-frame` - Set translation frame (`1`, `2`, or `3`). - `set-theme` - Set active theme (`everforest-dark`, `solarized-light`, `tokyo-night`, or `terminal-default`). -- `set-sequence-type` - Override auto-detection if it fails (`dna`, `aa`, or `full`). +- `set-sequence-type` - Override auto-detection if it fails (`dna`, `protein`, or `generic`). - `check-update` - Check for updates and show the latest version. - `quit` - Quit the app. @@ -246,7 +259,28 @@ Two methods are available for consensus calculation: If there is a tie for most common character, one is chosen at random. -Consensus is calculated in the background +The defailt is `majority-non-gap` + +### Quick translate vs full translate + +`salti` offers two methods of translating. The first is "quick translate" toggled by pressing `t` or the `toggle-translate` command, named as such because +it only translates the visible view and so is technically faster than the full translate + +Quick translate maps the currently visible nucleotides into their respetive codons according to the current frame and then renders them +as an amino acid overlay. This does not change the coordinate space - and the ruler will stay in nucleotides. Clicking on an amino acid however +will display the coordinate in protein space in the bottom status bar. + +Importantly quick translate can not be used if any of the columns have been filtered - as this would cause frameshifts and would not make sense to +represent. + +In contrast full translate (`Shift+T` or the `reload-as-protein` command) takes the full alignment and reloads it into a protein alignment, as if you +had loaded a fasta with the translated amino acids instead of nucleotides. This means the coordinate space becomes amino acids - and each amino acid +is represented by one column. This means full translate is compatible with column filtering, unlike quick translate. + +In practice this is still very fast (~3000 mpox virus sequences which are about 200kb big can be translated in under 0.1 seconds on my PC) and so it is useful +to switch between different translation modes depending on what is useful. + +Reading frame can be changed at any time using `Alt+1`, `Alt+2`, `Alt+3`, or the `set-translation-frame` command. ### Gap filtering @@ -257,8 +291,51 @@ Gap filtering changes the visible coordinate space. The ruler still shows absolu hidden columns have been skipped. A single jump is shown with an arrow pointing towards the side that has a jump. Dense regions of skipped columns are shown as a run of `~` characters rather than individual arrows. -Gap filtering and translation cannot be used at the same time. If translation is active, `filter-gaps` will be -rejected. Likewise if a gap filter is active, translation cannot be enabled until the filter is cleared. +Gap filtering and quick translation cannot be used at the same time. If quick translation is active, `filter-gaps` will be +rejected. Likewise if a gap filter is active, quick translation cannot be enabled until the filter is cleared. The full translation +(e.g using `reload-as-protein`) supports column filtering however. + +All column filters are applied after row filters. + +### Constant filtering + +`filter-constant` hides columns where any given value in the column meets the threshold you give it. Like gap filtering, the +threshold is also a percentage so `filter-constant 100` hides columns where 100% of the positions are the same (i.e removes constant sites). + +Like gap filtering - removing columns can change the visible coordinate space - see gap filtering for a more detailed breakdown of what that means. + +`filter-constant 99` hides columns where 99% of the positions are the same and so on. `filter-constant 0` clears the filter. + +NOTE: +Gaps are ignored when calculating constant fractions, in DNA alignments `N` is also ignored and in protein, `X` is also ignored + +### GFF support + +`salti` can load and display GFF3 annotations from a file with the `load-gff` command. This is currently experimental. + +At the moment only `gene` features are supported. + +When a GFF is loaded `salti` will show: +- a global feature pane +- a local feature track above the alignment +- feature details in the `Feature Info` pane when you hover over a feature +- support for the `jump-feature` command in the command palette + +You can also drag in the global feature pane to pan around the alignment. + +Currently annotations are treated as "global" - and per sequence annotations are not supported. This also means there is no fancy business that +tries to match GFF coordinates to gaps etc. This is mainly useful in specific use cases e.g: + +When you have multiple alignments to a reference sequence where that reference does not have gaps inserted (i.e insertions are ignored). +For example running mafft with something like `mafft --add --keeplength` or using alignments from something like [nextclade](https://github.com/nextstrain/nextclade) +or [fastalign](https://github.com/Sam-Sims/fastalign). + +In the normal nucleotide view, GFF coordinates are shown in nucleotide space. +Quick translate keeps this the same - because quick translate is just an amino acid overlay on top of the nucleotide view. +In full translate / `reload-as-protein`, the features are projected into protein columns using the active reading frame. + +If columns are filtered, features are projected onto the remaining visible columns. This means a feature can look compressed, or disappear entirely if all +of its columns are filtered out. ### Pinned behaviour @@ -270,7 +347,7 @@ rejected. Likewise if a gap filter is active, translation cannot be enabled unti - Input must be FASTA with equal sequence lengths across records. - Sequence type is auto-detected on load; you can override it if its wrong. - It samples up to 100 random alignments and compares NT and AA character fractions. If neither crosses 50%, it - falls back to `full` mode. + falls back to `generic` mode. ### Update check: diff --git a/assets/gff.gif b/assets/gff.gif new file mode 100644 index 0000000..e84e6f1 Binary files /dev/null and b/assets/gff.gif differ diff --git a/assets/salti_ss.png b/assets/salti_ss.png new file mode 100644 index 0000000..dc7cb45 Binary files /dev/null and b/assets/salti_ss.png differ diff --git a/libmsa/Cargo.toml b/libmsa/Cargo.toml index 3af67e8..86239c6 100644 --- a/libmsa/Cargo.toml +++ b/libmsa/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libmsa" -version = "0.1.0" +version = "0.2.0" edition = "2024" authors = ["Samuel Sims"] description = "A library for operations on multiple sequence alignments" @@ -12,5 +12,6 @@ categories = ["command-line-utilities", "science"] [dependencies] rand = "0.9" +rayon = "1.11" regex = "1.12.3" thiserror = "2.0.18" diff --git a/libmsa/src/alignment_type.rs b/libmsa/src/alignment_type.rs index 9fed65d..b08fab7 100644 --- a/libmsa/src/alignment_type.rs +++ b/libmsa/src/alignment_type.rs @@ -1,6 +1,6 @@ +use std::{num::NonZeroU8, str::FromStr}; + use crate::error::AlignmentError; -use std::num::NonZeroU8; -use std::str::FromStr; /// Describes the alignment type used by an alignment. /// diff --git a/libmsa/src/data.rs b/libmsa/src/data.rs index b09f960..f0747e5 100644 --- a/libmsa/src/data.rs +++ b/libmsa/src/data.rs @@ -1,31 +1,32 @@ use crate::error::AlignmentError; -/// Stores a raw sequence, before it has been validated into a [`Sequence`]. +/// Stores a raw sequence before it has been validated into an internal row. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RawSequence { pub id: String, pub sequence: Vec, } -/// Represents a validated sequence in an alignment. -/// -/// A `Sequence` will always have a non-empty sequence of bytes, -/// and all sequences in an alignment will have the same length #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Sequence { - id: String, - sequence: Box<[u8]>, +pub(crate) struct Sequence { + pub(crate) id: String, + pub(crate) sequence: Box<[u8]>, } -impl Sequence { - /// Returns the identifier (FASTA header). - pub fn id(&self) -> &str { - &self.id - } +impl TryFrom for Sequence { + type Error = AlignmentError; + + fn try_from(raw_sequence: RawSequence) -> Result { + if raw_sequence.sequence.is_empty() { + return Err(AlignmentError::EmptySequence { + id: raw_sequence.id, + }); + } - /// Returns sequence in bytes. - pub fn sequence(&self) -> &[u8] { - &self.sequence + Ok(Self { + id: raw_sequence.id, + sequence: raw_sequence.sequence.into_boxed_slice(), + }) } } @@ -36,49 +37,32 @@ pub(crate) struct AlignmentData { } impl AlignmentData { - pub(crate) fn from_raw(sequences: Vec) -> Result { - let mut raw_iter = sequences.into_iter(); - let Some(first) = raw_iter.next() else { + pub(crate) fn new(sequences: Vec) -> Result { + let mut sequences = sequences.into_iter(); + let Some(first) = sequences.next() else { return Err(AlignmentError::Empty); }; - if first.sequence.is_empty() { - return Err(AlignmentError::EmptySequence { id: first.id }); - } - - let width = first.sequence.len(); - let first = Sequence { - id: first.id, - sequence: first.sequence.into_boxed_slice(), - }; - - let mut normalised = Vec::with_capacity(1 + raw_iter.len()); + let length = first.sequence.len(); + let mut normalised = Vec::with_capacity(1 + sequences.len()); normalised.push(first); - let normalised = raw_iter.try_fold(normalised, |mut normalised, raw| { - if raw.sequence.is_empty() { - return Err(AlignmentError::EmptySequence { id: raw.id }); - } - - let actual = raw.sequence.len(); - if actual != width { + for sequence in sequences { + let actual = sequence.sequence.len(); + if actual != length { return Err(AlignmentError::LengthMismatch { - expected: width, + expected: length, actual, - id: raw.id, + id: sequence.id, }); } - normalised.push(Sequence { - id: raw.id, - sequence: raw.sequence.into_boxed_slice(), - }); - Ok(normalised) - })?; + normalised.push(sequence); + } Ok(Self { sequences: normalised, - length: width, + length, }) } } diff --git a/libmsa/src/detection.rs b/libmsa/src/detection.rs index 62937b6..ec55059 100644 --- a/libmsa/src/detection.rs +++ b/libmsa/src/detection.rs @@ -2,23 +2,20 @@ use std::num::NonZeroUsize; use rand::seq::IndexedRandom; -use crate::alignment_type::AlignmentType; -use crate::data::AlignmentData; -use crate::error::AlignmentError; +use crate::{alignment_type::AlignmentType, data::AlignmentData, error::AlignmentError}; -// cant be zero const DEFAULT_SAMPLE_SIZE: usize = 100; const DEFAULT_CLASSIFICATION_THRESHOLD: f32 = 0.5; -const NUCLEOTIDE_BYTES: &[u8] = b"ACGTURYSWKMBDHVN-."; -const PROTEIN_BYTES: &[u8] = b"DEFHIKLMNPQRSVWYX-."; +const NUCLEOTIDE_BYTES: &[u8] = b"ACGTURYSWKMBDHVN-"; +const PROTEIN_BYTES: &[u8] = b"DEFHIKLMNPQRSVWYX-"; /// Options that control alignment type detection. #[derive(Debug, Clone, Copy, PartialEq)] pub struct DetectionOptions { /// The maximum number of sequences to sample when classifying an alignment. sample_size: NonZeroUsize, - /// The minimum fraction of observed non-gap symbols that must match a - /// before that classification is accepted. + /// The minimum fraction of observed non-gap symbols that must match before + /// that classification is accepted. classification_threshold: f32, } @@ -71,8 +68,8 @@ pub(crate) fn detect_alignment_type( let (protein_count, nucleotide_count, total_count) = alignment .sequences .choose_multiple(rng, options.sample_size()) - .flat_map(|sequence| sequence.sequence().iter().copied()) - .filter(|byte| !matches!(byte, b'-' | b'.')) + .flat_map(|sequence| sequence.sequence.iter().copied()) + .filter(|byte| !matches!(byte, b'-')) .map(|byte| byte.to_ascii_uppercase()) .fold( (0usize, 0usize, 0usize), @@ -113,8 +110,7 @@ mod detect_alignment_type_tests { use rand::{SeedableRng, rngs::StdRng}; use super::{DetectionOptions, detect_alignment_type}; - use crate::data::AlignmentData; - use crate::{AlignmentError, AlignmentType, RawSequence}; + use crate::{AlignmentError, AlignmentType, RawSequence, data::AlignmentData}; fn raw(id: &str, sequence: &[u8]) -> RawSequence { RawSequence { @@ -124,7 +120,12 @@ mod detect_alignment_type_tests { } fn make_data(rows: &[(&str, &[u8])]) -> AlignmentData { - AlignmentData::from_raw(rows.iter().map(|(id, seq)| raw(id, seq)).collect()).unwrap() + let sequences = rows + .iter() + .map(|(id, seq)| raw(id, seq).try_into()) + .collect::, _>>() + .unwrap(); + AlignmentData::new(sequences).unwrap() } fn detect_with_seed( diff --git a/libmsa/src/error.rs b/libmsa/src/error.rs index 8123a94..54c9449 100644 --- a/libmsa/src/error.rs +++ b/libmsa/src/error.rs @@ -1,7 +1,6 @@ -use crate::alignment_type::AlignmentType; use thiserror::Error; -use crate::translation::ReadingFrame; +use crate::{alignment_type::AlignmentType, translation::ReadingFrame}; #[derive(Debug, Clone, PartialEq, Error)] pub enum AlignmentError { @@ -57,6 +56,9 @@ pub enum AlignmentError { /// A gap-filter threshold was outside the supported finite range. #[error("invalid gap fraction: {0} (expected a finite value in 0.0..=1.0)")] InvalidGapFraction(f32), + /// A constant-filter threshold was outside the supported finite range. + #[error("invalid constant fraction: {0} (expected a finite value in 0.0..=1.0)")] + InvalidConstantFraction(f32), /// A regex row-name filter could not be compiled. #[error("invalid regex '{pattern}'")] InvalidRegex { diff --git a/libmsa/src/filter.rs b/libmsa/src/filter.rs index cdf6284..1b13374 100644 --- a/libmsa/src/filter.rs +++ b/libmsa/src/filter.rs @@ -1,12 +1,9 @@ -use std::borrow::Borrow; -use std::sync::Arc; +use std::{borrow::Borrow, sync::Arc}; +use rayon::prelude::*; use regex::Regex; -use crate::error::AlignmentError; -use crate::metrics; -use crate::model::Alignment; -use crate::projection::Projection; +use crate::{error::AlignmentError, metrics, model::Alignment, projection::Projection}; /// Builder for a filtered view over an unfiltered [`Alignment`]. /// @@ -16,16 +13,28 @@ use crate::projection::Projection; /// 1. Regex - rows not matching [`Self::with_row_regex`] are removed. /// 2. Exclusion - explicit excludes ([`Self::without_rows`]) are removed last. /// -/// Column filters ([`Self::with_max_gap_fraction`]) run over the final row set. +/// Column filters ([`Self::with_max_gap_fraction`], [`Self::with_min_constant_fraction`]) run +/// over the final row set. #[derive(Debug, Clone)] pub struct FilterBuilder<'a> { source: &'a Alignment, row_exclude_sets: Vec>, row_name_regex: Option, max_gap_fraction: Option, + min_constant_fraction: Option, } impl<'a> FilterBuilder<'a> { + pub(crate) fn new(source: &'a Alignment) -> Self { + Self { + source, + row_exclude_sets: Vec::new(), + row_name_regex: None, + max_gap_fraction: None, + min_constant_fraction: None, + } + } + /// Excludes the supplied rows from the filtered view. pub fn without_rows(mut self, row_ids: I) -> Self where @@ -49,7 +58,13 @@ impl<'a> FilterBuilder<'a> { self } - /// Resolves all filters and builds a new [`Alignment`] + /// Keeps only columns whose dominant counted symbol fraction is below `threshold`. + pub fn with_min_constant_fraction(mut self, threshold: f32) -> Self { + self.min_constant_fraction = Some(threshold); + self + } + + /// Resolves all filters and builds a new [`Alignment`]. pub fn apply(self) -> Result { let row_count = self.source.row_count(); let column_count = self.source.column_count(); @@ -60,6 +75,9 @@ impl<'a> FilterBuilder<'a> { if let Some(max_gap_fraction) = self.max_gap_fraction { validate_gap_fraction(max_gap_fraction)?; } + if let Some(min_constant_fraction) = self.min_constant_fraction { + validate_constant_fraction(min_constant_fraction)?; + } let mut row_ids: Vec = (0..row_count).collect(); if let Some(pattern) = &self.row_name_regex { @@ -80,21 +98,36 @@ impl<'a> FilterBuilder<'a> { row_ids.retain(|&row_id| membership[row_id]); let mut column_ids: Vec = (0..column_count).collect(); - if let Some(max_gap_fraction) = self.max_gap_fraction { - let temp_rows = Projection::Filtered(Arc::from(row_ids.as_slice())); - let gap_fractions = metrics::counted_columns_range( - &self.source.data, - &temp_rows, - &self.source.columns, - 0..column_count, - ) - .map(|columns| metrics::gap_fraction_from_columns(&columns))?; - - column_ids.retain(|&column_id| { - gap_fractions - .get(column_id) - .is_none_or(|(_, gap_fraction)| *gap_fraction <= max_gap_fraction) - }); + if self.max_gap_fraction.is_some() || self.min_constant_fraction.is_some() { + let data = &self.source.data; + let active_type = self.source.active_type(); + let max_gap_fraction = self.max_gap_fraction; + let min_constant_fraction = self.min_constant_fraction; + + column_ids = (0..column_count) + .into_par_iter() + .filter_map(|abs_col| { + let mut counts = [0u32; 256]; + + for &abs_row in &row_ids { + let sequence = data + .sequences + .get(abs_row) + .expect("selected row must exist"); + counts[usize::from(sequence.sequence[abs_col])] += 1; + } + + let gap_ok = max_gap_fraction.is_none_or(|threshold| { + metrics::gap_fraction_from_counts(&counts) <= threshold + }); + let constant_ok = min_constant_fraction.is_none_or(|threshold| { + metrics::max_counted_symbol_fraction_from_counts(&counts, active_type) + .is_none_or(|fraction| fraction < threshold) + }); + + (gap_ok && constant_ok).then_some(abs_col) + }) + .collect(); } let rows_proj = if row_ids.len() == row_count { @@ -109,34 +142,10 @@ impl<'a> FilterBuilder<'a> { Projection::Filtered(Arc::from(column_ids)) }; - Ok(Alignment::from_selection( - Arc::clone(&self.source.data), - self.source.detected_type(), - self.source.active_type(), - rows_proj, - cols_proj, - )) - } -} - -impl<'a> FilterBuilder<'a> { - pub(crate) fn new(source: &'a Alignment) -> Self { - Self { - source, - row_exclude_sets: Vec::new(), - row_name_regex: None, - max_gap_fraction: None, - } + Ok(self.source.with_projections(rows_proj, cols_proj)) } } -fn compile_regex(pattern: &str) -> Result { - Regex::new(pattern).map_err(|error| AlignmentError::InvalidRegex { - pattern: pattern.to_string(), - source: error, - }) -} - fn validate_row_ids(row_ids: &[usize], row_count: usize) -> Result<(), AlignmentError> { let mut seen = vec![false; row_count]; for &row_id in row_ids { @@ -150,6 +159,7 @@ fn validate_row_ids(row_ids: &[usize], row_count: usize) -> Result<(), Alignment return Err(AlignmentError::DuplicateRowIndex { index: row_id }); } } + Ok(()) } @@ -161,11 +171,24 @@ fn validate_gap_fraction(threshold: f32) -> Result<(), AlignmentError> { Err(AlignmentError::InvalidGapFraction(threshold)) } +fn validate_constant_fraction(threshold: f32) -> Result<(), AlignmentError> { + if threshold.is_finite() && (0.0..=1.0).contains(&threshold) { + return Ok(()); + } + + Err(AlignmentError::InvalidConstantFraction(threshold)) +} + +fn compile_regex(pattern: &str) -> Result { + Regex::new(pattern).map_err(|error| AlignmentError::InvalidRegex { + pattern: pattern.to_string(), + source: error, + }) +} + #[cfg(test)] mod filter_builder_tests { - use crate::{ - Alignment, AlignmentError, AlignmentType, ColumnSummary, ConsensusMethod, RawSequence, - }; + use crate::{Alignment, AlignmentError, AlignmentType, RawSequence}; fn raw(id: &str, sequence: &[u8]) -> RawSequence { RawSequence { @@ -225,6 +248,36 @@ mod filter_builder_tests { assert_eq!(first_ids, second_ids); } + #[test] + fn constant_filter_is_order_insensitive() { + let alignment = dna_alignment(&[ + ("ref", b"AN-"), + ("keep-a", b"AAA"), + ("keep-b", b"AAA"), + ("drop", b"NT-"), + ]); + + let first = alignment + .filter() + .unwrap() + .without_rows([0]) + .with_min_constant_fraction(1.0) + .apply() + .unwrap(); + let second = alignment + .filter() + .unwrap() + .with_min_constant_fraction(1.0) + .without_rows([0]) + .apply() + .unwrap(); + + assert_eq!( + first.absolute_column_ids().collect::>(), + second.absolute_column_ids().collect::>() + ); + } + #[test] fn filter_supports_regex_and_index_filters() { let alignment = generic_alignment(&[ @@ -284,6 +337,18 @@ mod filter_builder_tests { assert_eq!(err, AlignmentError::InvalidGapFraction(1.5)); } + #[test] + fn invalid_constant_fraction_returns_error() { + let err = generic_alignment(&[("sample-1", b"AC")]) + .filter() + .unwrap() + .with_min_constant_fraction(1.5) + .apply() + .unwrap_err(); + + assert_eq!(err, AlignmentError::InvalidConstantFraction(1.5)); + } + #[test] fn gap_fraction_filter_uses_filtered_rows() { let alignment = dna_alignment(&[("ref", b"A"), ("s1", b"A"), ("s2", b"A"), ("s3", b"-")]); @@ -300,17 +365,62 @@ mod filter_builder_tests { } #[test] - fn column_summaries_positions_empty_returns_empty() { - let alignment = generic_alignment(&[("s1", b"A-"), ("s2", b"--")]); + fn constant_filter_hides_fully_constant_dna_columns() { + let alignment = dna_alignment(&[("s1", b"AN"), ("s2", b"AC"), ("s3", b"AT")]); + let filtered = alignment + .filter() + .unwrap() + .with_min_constant_fraction(1.0) + .apply() + .unwrap(); + + assert_eq!(filtered.absolute_column_ids().collect::>(), vec![1]); + } + + #[test] + fn constant_filter_uses_filtered_rows() { + let alignment = dna_alignment(&[("ref", b"T"), ("s1", b"A"), ("s2", b"A"), ("s3", b"T")]); + let filtered = alignment + .filter() + .unwrap() + .without_rows([0, 3]) + .with_min_constant_fraction(1.0) + .apply() + .unwrap(); + + assert!(filtered.absolute_column_ids().next().is_none()); + } + + #[test] + fn constant_filter_keeps_columns_with_only_ignored_symbols() { + let alignment = dna_alignment(&[("s1", b"N-"), ("s2", b"-N"), ("s3", b"NN")]); + let filtered = alignment + .filter() + .unwrap() + .with_min_constant_fraction(1.0) + .apply() + .unwrap(); assert_eq!( - alignment - .column_summaries_positions(&[], ConsensusMethod::MajorityNonGap) - .unwrap(), - Vec::::new() + filtered.absolute_column_ids().collect::>(), + vec![0, 1] ); } + #[test] + fn gap_and_constant_filters_can_be_combined() { + let alignment = dna_alignment(&[("s1", b"AA-"), ("s2", b"AA-"), ("s3", b"ATC")]); + let filtered = alignment + .filter() + .unwrap() + .with_max_gap_fraction(0.5) + .with_min_constant_fraction(0.9) + .apply() + .unwrap(); + + assert_eq!(filtered.absolute_column_ids().collect::>(), vec![1]); + } + #[test] fn filtered_alignment_rejects_chained_filter() { let alignment = dna_alignment(&[("s1", b"AC"), ("s2", b"TG")]); @@ -333,7 +443,7 @@ mod filter_builder_tests { #[cfg(test)] mod filtered_alignment_behaviour_tests { - use crate::{Alignment, AlignmentType, ConsensusMethod, RawSequence}; + use crate::{Alignment, AlignmentType, RawSequence}; fn raw(id: &str, sequence: &[u8]) -> RawSequence { RawSequence { @@ -342,16 +452,6 @@ mod filtered_alignment_behaviour_tests { } } - fn dna_alignment(rows: &[(&str, &[u8])]) -> Alignment { - Alignment::new_with_type( - rows.iter() - .map(|(id, seq)| raw(id, seq)) - .collect::>(), - AlignmentType::Dna, - ) - .unwrap() - } - fn generic_alignment(rows: &[(&str, &[u8])]) -> Alignment { Alignment::new_with_type( rows.iter() @@ -383,35 +483,4 @@ mod filtered_alignment_behaviour_tests { ); assert_eq!(filtered.sequence_by_absolute(2).unwrap().id(), "s2"); } - - #[test] - fn filter_consensus_uses_selected_rows() { - let alignment = - generic_alignment(&[("ref", b"TA"), ("s1", b"AC"), ("s2", b"AC"), ("s3", b"TC")]); - - let filtered = alignment - .filter() - .unwrap() - .without_rows([0]) - .apply() - .unwrap(); - let consensus = filtered - .consensus_positions(&[0, 1], ConsensusMethod::MajorityNonGap) - .unwrap(); - - assert_eq!(consensus, vec![(0, Some(b'A')), (1, Some(b'C'))]); - } - - #[test] - fn filter_conservation_uses_selected_rows() { - let alignment = dna_alignment(&[("s1", b"A"), ("s2", b"-"), ("s3", b"A")]); - let filtered = alignment - .filter() - .unwrap() - .without_rows([1]) - .apply() - .unwrap(); - - assert_eq!(filtered.conservation_positions(&[0]), Ok(vec![(0, 1.0)])); - } } diff --git a/libmsa/src/lib.rs b/libmsa/src/lib.rs index 74d6a74..42a0620 100644 --- a/libmsa/src/lib.rs +++ b/libmsa/src/lib.rs @@ -1,20 +1,20 @@ -pub mod alignment_type; +mod alignment_type; mod data; pub mod detection; -pub mod error; +mod error; mod filter; mod metrics; mod model; mod projection; -pub mod translation; +mod translation; pub use alignment_type::AlignmentType; -pub use data::{RawSequence, Sequence}; +pub use data::RawSequence; pub use detection::DetectionOptions; pub use error::AlignmentError; pub use filter::FilterBuilder; pub use metrics::{ColumnSummary, ConsensusMethod}; -pub use model::{Alignment, SequenceView}; +pub use model::{Alignment, RowView}; pub use translation::{ ReadingFrame, TranslatedAlignment, TranslatedSequenceView, TranslationTable, }; diff --git a/libmsa/src/metrics.rs b/libmsa/src/metrics.rs index 1e8466c..e74e2e8 100644 --- a/libmsa/src/metrics.rs +++ b/libmsa/src/metrics.rs @@ -1,11 +1,24 @@ -use rand::seq::IndexedRandom; use std::{num::NonZeroU8, ops::Range}; -use crate::data::AlignmentData; -use crate::error::AlignmentError; -use crate::model::Alignment; -use crate::projection::Projection; -use crate::translation::{ReadingFrame, TranslationTable, translated_byte_at}; +use rand::seq::IndexedRandom; +use rayon::prelude::*; + +use crate::{ + AlignmentType, + data::AlignmentData, + error::AlignmentError, + model::Alignment, + projection::Projection, + translation::{ReadingFrame, TranslationTable, translated_byte_at}, +}; + +/// Calculated values for a single alignment column. +#[derive(Debug, Clone, PartialEq)] +pub struct ColumnSummary { + pub position: usize, + pub consensus: Option, + pub conservation: Option, +} /// Selects how consensus bytes are chosen for alignment columns. /// @@ -51,127 +64,7 @@ impl std::str::FromStr for ConsensusMethod { } } -/// Calculated values for a single alignment column. -#[derive(Debug, Clone, PartialEq)] -pub struct ColumnSummary { - pub position: usize, - pub consensus: Option, - pub conservation: Option, - pub gap_fraction: f32, -} - -pub(crate) struct CountedColumn { - pub position: usize, - pub counts: [u32; 256], -} - -// alignment metrics impl Alignment { - /// Returns the consensus byte for each requested relative column. - /// - /// Each position is resolved against the alignment's current column projection. - /// The returned vector keeps the requested relative positions and contains one - /// consensus byte for each visible column named in `positions`. - /// - /// # Errors - /// - /// [`AlignmentError::ColumnOutOfBounds`] if any value in `positions` is not a - /// valid index in the current column projection. - pub fn consensus_positions( - &self, - positions: &[usize], - method: ConsensusMethod, - ) -> Result)>, AlignmentError> { - let columns = counted_columns_positions(&self.data, &self.rows, &self.columns, positions)?; - let mut rng = rand::rng(); - Ok(consensus_from_columns(&columns, method, &mut rng)) - } - - /// Returns the conservation score for each requested relative column. - /// - /// Each position is resolved against the alignment's current column projection. - /// The returned vector keeps the requested relative positions and contains one - /// conservation score for each visible column named in `positions`. - /// - /// # Errors - /// - /// [`AlignmentError::ColumnOutOfBounds`] if any value in `positions` is not a - /// valid index in the current column projection. - /// - /// [`AlignmentError::ConservationUndefined`] if the active alignment kind does not - /// define a conservation alphabet size. - pub fn conservation_positions( - &self, - positions: &[usize], - ) -> Result, AlignmentError> { - let columns = counted_columns_positions(&self.data, &self.rows, &self.columns, positions)?; - conservation_from_columns(&columns, self.active_type().conservation_alphabet_size()) - } - - /// Returns the gap fraction for each requested relative column. - /// - /// Each position is resolved against the alignment's current column projection. - /// The returned vector keeps the requested relative positions and contains one - /// gap fraction for each visible column named in `positions`. - /// - /// # Errors - /// - /// [`AlignmentError::ColumnOutOfBounds`] if any value in `positions` is not a - /// valid index in the current column projection. - pub fn gap_fraction_positions( - &self, - positions: &[usize], - ) -> Result, AlignmentError> { - let columns = counted_columns_positions(&self.data, &self.rows, &self.columns, positions)?; - Ok(gap_fraction_from_columns(&columns)) - } - - /// Returns the gap fraction for each relative column in `range`. - /// - /// Each position is resolved against the alignment's current column projection. - /// The returned vector keeps the requested relative positions and contains one - /// gap fraction for each visible column in `range`. - /// - /// # Errors - /// - /// [`AlignmentError::EmptyRange`] if `range` is empty. - /// - /// [`AlignmentError::ColumnOutOfBounds`] if `range.end` is greater than the - /// current column projection width. - pub fn gap_fraction_range( - &self, - range: Range, - ) -> Result, AlignmentError> { - let columns = counted_columns_range(&self.data, &self.rows, &self.columns, range)?; - Ok(gap_fraction_from_columns(&columns)) - } - - /// Returns a derived summary for each requested relative column. - /// - /// Each position is resolved against the alignment's current column projection. - /// The returned vector keeps the requested relative positions and contains a - /// [`ColumnSummary`] with consensus, gap fraction, and conservation when that - /// measure is defined for the active alignment kind. - /// - /// # Errors - /// - /// [`AlignmentError::ColumnOutOfBounds`] if any value in `positions` is not a - /// valid index in the current column projection. - pub fn column_summaries_positions( - &self, - positions: &[usize], - method: ConsensusMethod, - ) -> Result, AlignmentError> { - let columns = counted_columns_positions(&self.data, &self.rows, &self.columns, positions)?; - let mut rng = rand::rng(); - Ok(summaries_from_columns( - &columns, - method, - self.active_type().conservation_alphabet_size(), - &mut rng, - )) - } - /// Returns a derived summary for each column in `range`. /// /// Each position is resolved against the alignment's current column projection. @@ -205,29 +98,9 @@ impl Alignment { } } -pub(crate) fn counted_columns_positions( - data: &AlignmentData, - rows: &Projection, - columns: &Projection, - relative_positions: &[usize], -) -> Result, AlignmentError> { - relative_positions - .iter() - .copied() - .map(|rel_col| { - let abs_col = columns - .absolute(rel_col) - .ok_or(AlignmentError::ColumnOutOfBounds { - index: rel_col, - length: columns.len(), - })?; - - Ok(CountedColumn { - position: rel_col, - counts: column_byte_counts(data, rows, abs_col), - }) - }) - .collect() +pub(crate) struct CountedColumn { + pub position: usize, + pub counts: [u32; 256], } pub(crate) fn counted_columns_range( @@ -242,12 +115,13 @@ pub(crate) fn counted_columns_range( if range.end > columns.len() { return Err(AlignmentError::ColumnOutOfBounds { - index: range.end, + index: range.end - 1, length: columns.len(), }); } Ok(range + .into_par_iter() .map(|rel_col| CountedColumn { position: rel_col, counts: column_byte_counts( @@ -261,34 +135,6 @@ pub(crate) fn counted_columns_range( .collect()) } -pub(crate) fn counted_translated_columns_positions( - data: &AlignmentData, - rows: &Projection, - positions: &[usize], - frame: ReadingFrame, - table: &TranslationTable, -) -> Result, AlignmentError> { - let translated_len = frame.translated_length(data.length); - - positions - .iter() - .copied() - .map(|protein_col| { - if protein_col >= translated_len { - return Err(AlignmentError::ColumnOutOfBounds { - index: protein_col, - length: translated_len, - }); - } - - Ok(CountedColumn { - position: protein_col, - counts: translated_column_byte_counts(data, rows, protein_col, frame, table), - }) - }) - .collect() -} - pub(crate) fn counted_translated_columns_range( data: &AlignmentData, rows: &Projection, @@ -303,12 +149,13 @@ pub(crate) fn counted_translated_columns_range( let translated_len = frame.translated_length(data.length); if range.end > translated_len { return Err(AlignmentError::ColumnOutOfBounds { - index: range.end, + index: range.end - 1, length: translated_len, }); } Ok(range + .into_par_iter() .map(|protein_col| CountedColumn { position: protein_col, counts: translated_column_byte_counts(data, rows, protein_col, frame, table), @@ -316,48 +163,6 @@ pub(crate) fn counted_translated_columns_range( .collect()) } -pub(crate) fn consensus_from_columns( - columns: &[CountedColumn], - method: ConsensusMethod, - rng: &mut impl rand::Rng, -) -> Vec<(usize, Option)> { - columns - .iter() - .map(|column| { - ( - column.position, - consensus_from_counts(&column.counts, method, rng), - ) - }) - .collect() -} - -pub(crate) fn conservation_from_columns( - columns: &[CountedColumn], - alphabet_size: Option, -) -> Result, AlignmentError> { - let max_entropy = alphabet_size - .map(|value| f64::from(value.get()).log2()) - .ok_or(AlignmentError::ConservationUndefined)?; - - Ok(columns - .iter() - .map(|column| { - ( - column.position, - conservation_from_counts(&column.counts, max_entropy), - ) - }) - .collect()) -} - -pub(crate) fn gap_fraction_from_columns(columns: &[CountedColumn]) -> Vec<(usize, f32)> { - columns - .iter() - .map(|column| (column.position, gap_fraction_from_counts(&column.counts))) - .collect() -} - #[inline] const fn is_gap_byte(byte: u8) -> bool { matches!(byte, b'-') @@ -378,7 +183,6 @@ pub(crate) fn summaries_from_columns( consensus: consensus_from_counts(&column.counts, method, rng), conservation: max_entropy .map(|max_entropy| conservation_from_counts(&column.counts, max_entropy)), - gap_fraction: gap_fraction_from_counts(&column.counts), }) .collect() } @@ -405,6 +209,29 @@ pub(crate) fn gap_fraction_from_counts(counts: &[u32; 256]) -> f32 { } } +pub(crate) fn max_counted_symbol_fraction_from_counts( + counts: &[u32; 256], + kind: AlignmentType, +) -> Option { + let mut counted_total = 0u32; + let mut max_count = 0u32; + + for (symbol, &count) in counts.iter().enumerate() { + if count == 0 { + continue; + } + + if is_ignored_constant_symbol(symbol as u8, kind) { + continue; + } + + counted_total += count; + max_count = max_count.max(count); + } + + (counted_total != 0).then_some(max_count as f32 / counted_total as f32) +} + fn consensus_from_counts( counts: &[u32; 256], method: ConsensusMethod, @@ -481,6 +308,19 @@ fn conservation_from_counts(counts: &[u32; 256], max_entropy: f64) -> f32 { (conservation * (1.0 - gap_fraction)) as f32 } +#[inline] +const fn is_ignored_constant_symbol(byte: u8, kind: AlignmentType) -> bool { + if is_gap_byte(byte) { + return true; + } + + match kind { + AlignmentType::Dna => matches!(byte, b'N' | b'n'), + AlignmentType::Protein => matches!(byte, b'X' | b'x'), + AlignmentType::Generic => false, + } +} + fn column_byte_counts(data: &AlignmentData, rows: &Projection, abs_col: usize) -> [u32; 256] { let mut counts = [0u32; 256]; @@ -489,7 +329,7 @@ fn column_byte_counts(data: &AlignmentData, rows: &Projection, abs_col: usize) - .sequences .get(abs_row) .expect("selected row must exist"); - counts[usize::from(sequence.sequence()[abs_col])] += 1; + counts[usize::from(sequence.sequence[abs_col])] += 1; } counts @@ -509,7 +349,7 @@ fn translated_column_byte_counts( .sequences .get(abs_row) .expect("selected row must exist"); - let byte = translated_byte_at(sequence.sequence(), protein_col, frame, table) + let byte = translated_byte_at(&sequence.sequence, protein_col, frame, table) .expect("validated translated range"); counts[usize::from(byte)] += 1; } @@ -586,7 +426,7 @@ mod derived_column_tests { use rand::{SeedableRng, rngs::StdRng}; - use super::{ConsensusMethod, CountedColumn, consensus_from_columns, summaries_from_columns}; + use super::{ConsensusMethod, CountedColumn, summaries_from_columns}; fn counted_column(position: usize, symbols: &[u8]) -> CountedColumn { let mut counts = [0u32; 256]; @@ -597,33 +437,6 @@ mod derived_column_tests { CountedColumn { position, counts } } - #[test] - fn consensus_from_columns_returns_known_consensus() { - let columns = vec![counted_column(2, b"AAAA"), counted_column(4, b"CCCC")]; - let mut rng = StdRng::seed_from_u64(6); - - assert_eq!( - consensus_from_columns(&columns, ConsensusMethod::MajorityNonGap, &mut rng), - vec![(2, Some(b'A')), (4, Some(b'C'))] - ); - } - - #[test] - fn consensus_from_columns_respects_gap_handling() { - let columns = vec![counted_column(1, b"--A")]; - let mut majority_rng = StdRng::seed_from_u64(7); - let mut nongap_rng = StdRng::seed_from_u64(7); - - assert_eq!( - consensus_from_columns(&columns, ConsensusMethod::Majority, &mut majority_rng), - vec![(1, Some(b'-'))] - ); - assert_eq!( - consensus_from_columns(&columns, ConsensusMethod::MajorityNonGap, &mut nongap_rng,), - vec![(1, Some(b'A'))] - ); - } - #[test] fn summaries_from_columns_return_none_for_all_gap_column() { let columns = vec![counted_column(3, b"---")]; @@ -639,7 +452,6 @@ mod derived_column_tests { assert_eq!(summaries[0].position, 3); assert_eq!(summaries[0].consensus, None); assert_eq!(summaries[0].conservation, Some(0.0)); - assert_eq!(summaries[0].gap_fraction, 1.0); } #[test] @@ -657,11 +469,9 @@ mod derived_column_tests { assert_eq!(summaries[0].position, 0); assert_eq!(summaries[0].consensus, Some(b'A')); assert_eq!(summaries[0].conservation, Some(1.0)); - assert_eq!(summaries[0].gap_fraction, 0.0); assert_eq!(summaries[1].position, 1); assert_eq!(summaries[1].consensus, None); assert_eq!(summaries[1].conservation, Some(0.0)); - assert_eq!(summaries[1].gap_fraction, 1.0); } } @@ -719,88 +529,60 @@ mod conservation_count_tests { } #[cfg(test)] -mod tests { - use crate::{Alignment, AlignmentError, AlignmentType, ConsensusMethod, RawSequence}; +mod constant_fraction_count_tests { + use super::max_counted_symbol_fraction_from_counts; + use crate::AlignmentType; - fn raw(id: &str, sequence: &[u8]) -> RawSequence { - RawSequence { - id: id.to_string(), - sequence: sequence.to_vec(), + fn counts_for(symbols: &[u8]) -> [u32; 256] { + let mut counts = [0u32; 256]; + for &s in symbols { + counts[usize::from(s)] += 1; } + counts } #[test] - fn consensus_positions_returns_single_column() { - let alignment = Alignment::new_with_type( - vec![raw("s1", b"ACGT"), raw("s2", b"ACGT")], - AlignmentType::Dna, - ) - .unwrap(); + fn dna_constant_fraction_ignores_gaps_and_ns() { + let counts = counts_for(b"AANn--T"); + let fraction = max_counted_symbol_fraction_from_counts(&counts, AlignmentType::Dna); - assert_eq!( - alignment - .consensus_positions(&[1], ConsensusMethod::MajorityNonGap) - .unwrap(), - vec![(1, Some(b'C'))] - ); + assert_eq!(fraction, Some(2.0 / 3.0)); } #[test] - fn consensus_positions_returns_correct_positions() { - let alignment = Alignment::new_with_type( - vec![raw("s1", b"ACGT"), raw("s2", b"ACGT")], - AlignmentType::Dna, - ) - .unwrap(); + fn protein_constant_fraction_ignores_gaps_and_xs() { + let counts = counts_for(b"MMXx--K"); + let fraction = max_counted_symbol_fraction_from_counts(&counts, AlignmentType::Protein); - assert_eq!( - alignment - .consensus_positions(&[1, 2], ConsensusMethod::MajorityNonGap) - .unwrap(), - vec![(1, Some(b'C')), (2, Some(b'G'))] - ); + assert_eq!(fraction, Some(2.0 / 3.0)); } #[test] - fn translated_consensus_range_returns_protein_positions() { - let alignment = Alignment::new_with_type( - vec![raw("s1", b"ATGAAA"), raw("s2", b"ATGAAG")], - AlignmentType::Dna, - ) - .unwrap(); + fn generic_constant_fraction_counts_n() { + let counts = counts_for(b"NN-A"); + let fraction = max_counted_symbol_fraction_from_counts(&counts, AlignmentType::Generic); - assert_eq!( - alignment - .translated(crate::ReadingFrame::Frame1) - .unwrap() - .consensus_range(0..2, ConsensusMethod::MajorityNonGap) - .unwrap(), - vec![(0, Some(b'M')), (1, Some(b'K'))] - ); + assert_eq!(fraction, Some(2.0 / 3.0)); } #[test] - fn translated_column_summaries_positions_return_protein_metrics() { - let alignment = Alignment::new_with_type( - vec![raw("s1", b"GCT"), raw("s2", b"GCC")], - AlignmentType::Dna, - ) - .unwrap(); - - let raw_summary = alignment - .column_summaries_positions(&[2], ConsensusMethod::MajorityNonGap) - .unwrap(); - let translated_summary = alignment - .translated(crate::ReadingFrame::Frame1) - .unwrap() - .column_summaries_positions(&[0], ConsensusMethod::MajorityNonGap) - .unwrap(); - - assert_eq!(translated_summary.len(), 1); - assert_eq!(translated_summary[0].position, 0); - assert_eq!(translated_summary[0].consensus, Some(b'A')); - assert_eq!(translated_summary[0].conservation, Some(1.0)); - assert!(raw_summary[0].conservation.unwrap() < 1.0); + fn constant_fraction_returns_none_when_all_symbols_are_ignored() { + let counts = counts_for(b"-Nn"); + let fraction = max_counted_symbol_fraction_from_counts(&counts, AlignmentType::Dna); + + assert_eq!(fraction, None); + } +} + +#[cfg(test)] +mod tests { + use crate::{Alignment, AlignmentType, ConsensusMethod, RawSequence}; + + fn raw(id: &str, sequence: &[u8]) -> RawSequence { + RawSequence { + id: id.to_string(), + sequence: sequence.to_vec(), + } } #[test] @@ -819,78 +601,4 @@ mod tests { vec![0, 1] ); } - - #[test] - fn conservation_positions_returns_score() { - let alignment = - Alignment::new_with_type(vec![raw("s1", b"A"), raw("s2", b"A")], AlignmentType::Dna) - .unwrap(); - - assert_eq!( - alignment.conservation_positions(&[0]).unwrap(), - vec![(0, 1.0)] - ); - } - - #[test] - fn gap_fraction_range_returns_requested_positions() { - let alignment = - Alignment::new_with_type(vec![raw("s1", b"A-"), raw("s2", b"--")], AlignmentType::Dna) - .unwrap(); - - assert_eq!( - alignment.gap_fraction_range(0..2).unwrap(), - vec![(0, 0.5), (1, 1.0)] - ); - } - - #[test] - fn conservation_positions_is_undefined_for_generic() { - let alignment = Alignment::new_with_type( - vec![raw("s1", b"A"), raw("s2", b"A")], - AlignmentType::Generic, - ) - .unwrap(); - - assert_eq!( - alignment.conservation_positions(&[0]), - Err(AlignmentError::ConservationUndefined) - ); - } - - #[test] - fn gap_fraction_positions_returns_values() { - let alignment = Alignment::new_with_type( - vec![raw("s1", b"A-"), raw("s2", b"--"), raw("s3", b"AA")], - AlignmentType::Dna, - ) - .unwrap(); - - let fractions = alignment.gap_fraction_positions(&[0, 1]).unwrap(); - assert!((fractions[0].1 - (1.0 / 3.0)).abs() < f32::EPSILON); - assert!((fractions[1].1 - (2.0 / 3.0)).abs() < f32::EPSILON); - } - - #[test] - fn column_summaries_positions_returns_all_metrics() { - let alignment = Alignment::new_with_type( - vec![raw("s1", b"A-"), raw("s2", b"--"), raw("s3", b"AA")], - AlignmentType::Dna, - ) - .unwrap(); - - let summaries = alignment - .column_summaries_positions(&[0, 1], ConsensusMethod::MajorityNonGap) - .unwrap(); - - assert_eq!(summaries.len(), 2); - assert_eq!(summaries[0].position, 0); - assert_eq!(summaries[0].consensus, Some(b'A')); - assert!(summaries[0].conservation.is_some()); - assert!((summaries[0].gap_fraction - (1.0 / 3.0)).abs() < f32::EPSILON); - assert_eq!(summaries[1].position, 1); - assert_eq!(summaries[1].consensus, Some(b'A')); - assert!(summaries[1].conservation.is_some()); - assert!((summaries[1].gap_fraction - (2.0 / 3.0)).abs() < f32::EPSILON); - } } diff --git a/libmsa/src/model.rs b/libmsa/src/model.rs index 1aba548..b652f33 100644 --- a/libmsa/src/model.rs +++ b/libmsa/src/model.rs @@ -1,13 +1,18 @@ -use std::ops::Range; -use std::sync::Arc; +use std::{ops::Range, sync::Arc}; -use crate::alignment_type::AlignmentType; -use crate::data::{AlignmentData, RawSequence}; -use crate::detection::{DetectionOptions, detect_alignment_type}; -use crate::error::AlignmentError; -use crate::filter::FilterBuilder; -use crate::projection::Projection; -use crate::translation::{ReadingFrame, TranslatedAlignment, TranslationTable}; +use rand::{SeedableRng, rngs::StdRng}; + +use crate::{ + alignment_type::AlignmentType, + data::{AlignmentData, RawSequence}, + detection::{DetectionOptions, detect_alignment_type}, + error::AlignmentError, + filter::FilterBuilder, + projection::Projection, + translation::{ReadingFrame, TranslatedAlignment, TranslationTable}, +}; + +const DETECTION_SEED: u64 = u64::from_be_bytes(*b"REDRIGHT"); /// A multiple sequence alignment. /// @@ -24,22 +29,6 @@ pub struct Alignment { pub(crate) columns: Projection, } -/// A borrowed view of one sequence row within an [`Alignment`]. -/// -/// `SequenceView` does not own sequence data. Instead, it exposes a single row -/// from an alignment together with that alignment's current column projection -/// and active kind. This means its column-based accessors operate on the -/// visible columns of the parent alignment rather than the full -/// underlying sequence. -#[derive(Debug, Clone, Copy)] -pub struct SequenceView<'a> { - absolute_row_id: usize, - id: &'a str, - data: &'a [u8], - columns: &'a Projection, -} - -// constructors impl Alignment { /// Creates an alignment from raw sequences and detects its kind using the default detection options. /// @@ -54,7 +43,7 @@ impl Alignment { /// /// [`AlignmentError::LengthMismatch`] if the sequences in `seqs` do not all have the same length. pub fn new(seqs: impl IntoIterator) -> Result { - Self::new_with(seqs, DetectionOptions::default()) + Self::new_with_detection_options(seqs, DetectionOptions::default()) } /// Creates an alignment from raw sequences and detects its kind using the supplied detection options. @@ -69,12 +58,13 @@ impl Alignment { /// [`AlignmentError::EmptySequence`] if any sequence in `seqs` has an empty sequence. /// /// [`AlignmentError::LengthMismatch`] if the sequences in `seqs` do not all have the same length. - pub fn new_with( + pub fn new_with_detection_options( seqs: impl IntoIterator, options: DetectionOptions, ) -> Result { - let data = AlignmentData::from_raw(seqs.into_iter().collect())?; - let detected = detect_alignment_type(&data, options, &mut rand::rng()); + let data = data_from_raw_sequences(seqs)?; + let mut rng = StdRng::seed_from_u64(DETECTION_SEED); + let detected = detect_alignment_type(&data, options, &mut rng); Ok(Self::from_data(data, detected)) } @@ -90,17 +80,38 @@ impl Alignment { /// [`AlignmentError::EmptySequence`] if any sequence in `seqs` has an empty sequence. /// /// [`AlignmentError::LengthMismatch`] if the sequences in `seqs` do not all have the same length. - pub fn new_with_type( + pub(crate) fn new_with_type( seqs: impl IntoIterator, kind: AlignmentType, ) -> Result { - let data = AlignmentData::from_raw(seqs.into_iter().collect())?; + let data = data_from_raw_sequences(seqs)?; Ok(Self::from_data(data, kind)) } -} -// getter methods -impl Alignment { + pub(crate) fn from_data(data: AlignmentData, alignment_type: AlignmentType) -> Self { + let rows = Projection::Full { + len: data.sequences.len(), + }; + let columns = Projection::Full { len: data.length }; + Self { + data: Arc::new(data), + detected_type: alignment_type, + active_type: alignment_type, + rows, + columns, + } + } + + pub(crate) fn with_projections(&self, rows: Projection, columns: Projection) -> Self { + Self { + data: Arc::clone(&self.data), + detected_type: self.detected_type, + active_type: self.active_type, + rows, + columns, + } + } + /// Returns the number of visible sequences. /// /// This is the length of the alignment's current row projection. For a filtered alignment, it @@ -126,7 +137,7 @@ impl Alignment { .sequences .get(abs_row) .expect("selected row must exist") - .id() + .id .chars() .count() }) @@ -134,43 +145,43 @@ impl Alignment { .unwrap_or(0) } - /// Returns a [`SequenceView`] for the visible sequence at `relative_row`. + /// Returns a [`RowView`] for the visible sequence at `relative_row`. /// /// The row index is relative to this alignment's current row projection, so `0` refers to the first /// visible sequence rather than the first sequence in the underlying data. The returned - /// [`SequenceView`] also uses this alignment's current column projection and active kind. + /// [`RowView`] also uses this alignment's current column projection and active kind. /// /// Returns `None` if `relative_row` does not refer to a visible row. - pub fn sequence(&self, relative_row: usize) -> Option> { + pub fn sequence(&self, relative_row: usize) -> Option> { let abs_row = self.rows.absolute(relative_row)?; let seq = self.data.sequences.get(abs_row)?; - Some(SequenceView { + Some(RowView { absolute_row_id: abs_row, - id: seq.id(), - data: seq.sequence(), + id: &seq.id, + data: &seq.sequence, columns: &self.columns, }) } - /// Returns a [`SequenceView`] for the visible sequence at `absolute_row`. + /// Returns a [`RowView`] for the visible sequence at `absolute_row`. /// /// The row index refers to the underlying alignment data rather than this alignment's current row - /// projection. The returned [`SequenceView`] is produced only if that absolute row is still visible + /// projection. The returned [`RowView`] is produced only if that absolute row is still visible /// in this alignment, and it uses this alignment's current column projection and active kind. /// /// Returns `None` if `absolute_row` is out of bounds or refers to a row that is not visible. - pub fn sequence_by_absolute(&self, absolute_row: usize) -> Option> { + pub(crate) fn sequence_by_absolute(&self, absolute_row: usize) -> Option> { self.rows.relative(absolute_row)?; let seq = self.data.sequences.get(absolute_row)?; - Some(SequenceView { + Some(RowView { absolute_row_id: absolute_row, - id: seq.id(), - data: seq.sequence(), + id: &seq.id, + data: &seq.sequence, columns: &self.columns, }) } - /// Returns a [`SequenceView`] for the absolute row but projected + /// Returns a [`RowView`] for the absolute row but projected /// through this alignment's current column projection. /// /// Unlike [`sequence_by_absolute`], this method does not require `abs_row` @@ -178,19 +189,16 @@ impl Alignment { /// /// Returns `None` only when `abs_row` is out of bounds for the underlying /// alignment data. - pub fn project_absolute_row(&self, abs_row: usize) -> Option> { + pub fn project_absolute_row(&self, abs_row: usize) -> Option> { let seq = self.data.sequences.get(abs_row)?; - Some(SequenceView { + Some(RowView { absolute_row_id: abs_row, - id: seq.id(), - data: seq.sequence(), + id: &seq.id, + data: &seq.sequence, columns: &self.columns, }) } -} -// coordinate functions -impl Alignment { /// Returns the absolute row index for `relative`, or `None` if `relative` is not visible. pub fn absolute_row_id(&self, relative: usize) -> Option { self.rows.absolute(relative) @@ -202,7 +210,8 @@ impl Alignment { } /// Returns an iterator over the visible rows absolute index. - pub fn absolute_row_ids(&self) -> impl ExactSizeIterator + '_ { + #[cfg(test)] + pub(crate) fn absolute_row_ids(&self) -> impl ExactSizeIterator + '_ { self.rows.iter() } @@ -220,13 +229,18 @@ impl Alignment { pub fn relative_column_id(&self, absolute: usize) -> Option { self.columns.relative(absolute) } -} -// type and overrides -impl Alignment { - /// Returns the type that was assigned when this alignment was created. - pub fn detected_type(&self) -> AlignmentType { - self.detected_type + /// Returns the visible relative column range covered by `absolute_range`. + /// + /// The returned range uses this view's visible column indices and includes every visible + /// column whose absolute ID is inside `absolute_range`. + /// + /// Returns `None` when none of the columns in `absolute_range` are visible. + pub fn relative_column_range_intersecting( + &self, + absolute_range: Range, + ) -> Option> { + self.columns.relative_range_intersecting(absolute_range) } /// Returns the type currently used to interpret this alignment. @@ -239,14 +253,6 @@ impl Alignment { self.active_type = alignment_type; } - /// Clears any active type override and restores the detected type. - pub fn clear_override_type(&mut self) { - self.active_type = self.detected_type; - } -} - -// operations -impl Alignment { /// Creates a lazy translated view over this alignment with a specific translation table. /// /// # Errors @@ -298,6 +304,7 @@ impl Alignment { kind: self.active_type, }); } + Ok(FilterBuilder::new(self)) } @@ -307,7 +314,22 @@ impl Alignment { } } -impl<'a> SequenceView<'a> { +/// A borrowed view of one sequence row within an [`Alignment`]. +/// +/// `RowView` does not own sequence data. Instead, it exposes a single row +/// from an alignment together with that alignment's current column projection +/// and active kind. This means its column-based accessors operate on the +/// visible columns of the parent alignment rather than the full +/// underlying sequence. +#[derive(Debug, Clone, Copy)] +pub struct RowView<'a> { + absolute_row_id: usize, + id: &'a str, + data: &'a [u8], + columns: &'a Projection, +} + +impl<'a> RowView<'a> { /// Returns the absolute row index of this sequence. pub fn absolute_row_id(&self) -> usize { self.absolute_row_id @@ -326,9 +348,9 @@ impl<'a> SequenceView<'a> { self.columns.len() } - /// Returns `true` when there are no visible columns. + /// Returns true if this sequence view has no visible columns. pub fn is_empty(&self) -> bool { - self.columns.is_empty() + self.columns.len() == 0 } /// Returns the byte at `relative_col`, or `None` if the column is out of bounds. @@ -356,12 +378,14 @@ impl<'a> SequenceView<'a> { if range.is_empty() { return Err(AlignmentError::EmptyRange); } + if range.end > self.columns.len() { return Err(AlignmentError::ColumnOutOfBounds { index: range.end - 1, length: self.columns.len(), }); } + let columns = self.columns; let data = self.data; Ok(range.map(move |rel_col| { @@ -371,36 +395,14 @@ impl<'a> SequenceView<'a> { } } -impl Alignment { - pub(crate) fn from_data(data: AlignmentData, alignment_type: AlignmentType) -> Self { - let rows = Projection::Full { - len: data.sequences.len(), - }; - let columns = Projection::Full { len: data.length }; - Self { - data: Arc::new(data), - detected_type: alignment_type, - active_type: alignment_type, - rows, - columns, - } - } - - pub(crate) fn from_selection( - data: Arc, - detected_kind: AlignmentType, - active_kind: AlignmentType, - rows: Projection, - columns: Projection, - ) -> Self { - Self { - data, - detected_type: detected_kind, - active_type: active_kind, - rows, - columns, - } - } +fn data_from_raw_sequences( + sequences: impl IntoIterator, +) -> Result { + let sequences = sequences + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?; + AlignmentData::new(sequences) } #[cfg(test)] @@ -420,7 +422,7 @@ mod alignment_construction_tests { let alignment = Alignment::new(vec![raw("seq-1", b"ACGT"), raw("seq-2", b"TGCA")]).unwrap(); assert_eq!(alignment.column_count(), 4); assert_eq!(alignment.row_count(), 2); - assert_eq!(alignment.detected_type(), AlignmentType::Dna); + assert_eq!(alignment.active_type(), AlignmentType::Dna); } #[test] @@ -430,7 +432,6 @@ mod alignment_construction_tests { AlignmentType::Protein, ) .unwrap(); - assert_eq!(alignment.detected_type(), AlignmentType::Protein); assert_eq!(alignment.active_type(), AlignmentType::Protein); } @@ -447,16 +448,12 @@ mod alignment_construction_tests { } #[test] - fn override_type_methods_work() { + fn override_type_method_updates_active_type() { let mut alignment = Alignment::new(vec![raw("seq-1", b"ACGT"), raw("seq-2", b"TGCA")]).unwrap(); alignment.set_override_type(AlignmentType::Protein); - assert_eq!(alignment.detected_type(), AlignmentType::Dna); assert_eq!(alignment.active_type(), AlignmentType::Protein); - - alignment.clear_override_type(); - assert_eq!(alignment.active_type(), AlignmentType::Dna); } #[test] @@ -525,10 +522,7 @@ mod alignment_access_tests { #[should_panic(expected = "selected row must exist")] fn max_id_len_panics_for_invalid_row_projection() { let alignment = Alignment::new(vec![raw("s1", b"AC")]).unwrap(); - let filtered = Alignment::from_selection( - alignment.data.clone(), - alignment.detected_type(), - alignment.active_type(), + let filtered = alignment.with_projections( Projection::Filtered(Arc::from(vec![1usize])), Projection::Full { len: alignment.column_count(), @@ -542,7 +536,7 @@ mod alignment_access_tests { fn sequence_by_absolute_full() { let alignment = Alignment::new(vec![raw("s1", b"AC"), raw("s2", b"TG")]).unwrap(); let sv = alignment.sequence_by_absolute(1).unwrap(); - assert_eq!(sv.id(), "s2"); + assert_eq!(sv.id, "s2"); assert!(alignment.sequence_by_absolute(2).is_none()); } @@ -574,10 +568,7 @@ mod alignment_access_tests { #[test] fn indexed_bytes_range_filtered() { let alignment = Alignment::new(vec![raw("s1", b"ACGT")]).unwrap(); - let filtered = Alignment::from_selection( - alignment.data.clone(), - alignment.detected_type(), - alignment.active_type(), + let filtered = alignment.with_projections( Projection::Full { len: alignment.row_count(), }, @@ -634,7 +625,7 @@ mod alignment_projection_tests { (base, filtered) } - fn indexed_bytes(view: SequenceView<'_>) -> Vec<(usize, u8)> { + fn indexed_bytes(view: RowView<'_>) -> Vec<(usize, u8)> { view.indexed_bytes_range(0..view.len()).unwrap().collect() } @@ -662,10 +653,7 @@ mod alignment_projection_tests { raw("s3", b"AAAA"), ]) .unwrap(); - let filtered = Alignment::from_selection( - alignment.data.clone(), - alignment.detected_type(), - alignment.active_type(), + let filtered = alignment.with_projections( Projection::Filtered(Arc::from(vec![0, 2])), Projection::Filtered(Arc::from(vec![1, 3])), ); @@ -687,6 +675,38 @@ mod alignment_projection_tests { assert_eq!(filtered.absolute_column_id(2), None); } + #[test] + fn relative_column_range_intersecting_full() { + let alignment = Alignment::new(vec![raw("s1", b"ACGT")]).unwrap(); + + assert_eq!( + alignment.relative_column_range_intersecting(1..3), + Some(1..3) + ); + assert_eq!( + alignment.relative_column_range_intersecting(2..99), + Some(2..4) + ); + assert_eq!(alignment.relative_column_range_intersecting(4..8), None); + } + + #[test] + fn relative_column_range_intersecting_filtered() { + let alignment = Alignment::new(vec![raw("s1", b"ACGTACGT")]).unwrap(); + let filtered = alignment.with_projections( + Projection::Full { + len: alignment.row_count(), + }, + Projection::Filtered(Arc::from(vec![1usize, 3, 4, 7])), + ); + + assert_eq!( + filtered.relative_column_range_intersecting(2..6), + Some(1..3) + ); + assert_eq!(filtered.relative_column_range_intersecting(5..6), None); + } + #[test] fn unfiltered_matches_sequence() { let alignment = Alignment::new(vec![raw("s1", b"ACGT"), raw("s2", b"TGCA")]).unwrap(); @@ -714,7 +734,7 @@ mod alignment_projection_tests { assert!(filtered.sequence_by_absolute(1).is_none()); let sv = filtered.project_absolute_row(1).unwrap(); - assert_eq!(sv.id(), "s2"); + assert_eq!(sv.id, "s2"); assert_eq!(sv.absolute_row_id(), 1); } @@ -726,7 +746,7 @@ mod alignment_projection_tests { let via_sba = filtered.sequence_by_absolute(abs).unwrap(); let via_proj = filtered.project_absolute_row(abs).unwrap(); - assert_eq!(via_sba.id(), via_proj.id()); + assert_eq!(via_sba.id, via_proj.id); assert_eq!(via_sba.absolute_row_id(), via_proj.absolute_row_id()); assert_eq!(via_sba.len(), via_proj.len()); assert_eq!(indexed_bytes(via_sba), indexed_bytes(via_proj)); @@ -736,10 +756,7 @@ mod alignment_projection_tests { #[test] fn column_projection_applied_to_excluded_row() { let base = Alignment::new(vec![raw("visible", b"ACGT"), raw("excluded", b"TTCA")]).unwrap(); - let col_filtered = Alignment::from_selection( - base.data.clone(), - base.detected_type(), - base.active_type(), + let col_filtered = base.with_projections( Projection::Filtered(Arc::from(vec![0usize])), Projection::Filtered(Arc::from(vec![0usize, 2])), ); diff --git a/libmsa/src/projection.rs b/libmsa/src/projection.rs index c71d776..9cd4d76 100644 --- a/libmsa/src/projection.rs +++ b/libmsa/src/projection.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{ops::Range, sync::Arc}; #[derive(Debug, Clone)] pub(crate) enum Projection { @@ -19,10 +19,6 @@ impl Projection { } } - pub(crate) fn is_empty(&self) -> bool { - self.len() == 0 - } - pub(crate) fn absolute(&self, relative: usize) -> Option { match self { Self::Full { len } => (relative < *len).then_some(relative), @@ -37,6 +33,18 @@ impl Projection { } } + pub(crate) fn relative_range_intersecting(&self, range: Range) -> Option> { + let range = match self { + Self::Full { len } => range.start.min(*len)..range.end.min(*len), + Self::Filtered(ids) => { + let start = ids.partition_point(|&column| column < range.start); + let end = ids.partition_point(|&column| column < range.end); + start..end + } + }; + (!range.is_empty()).then_some(range) + } + pub(crate) fn iter(&self) -> ProjectionIter<'_> { match self { Self::Full { len } => ProjectionIter::Full(0..*len), @@ -77,7 +85,6 @@ mod tests { fn full_projection_basics() { let proj = Projection::Full { len: 5 }; assert_eq!(proj.len(), 5); - assert!(!proj.is_empty()); assert!(proj.is_full()); assert_eq!(proj.absolute(0), Some(0)); assert_eq!(proj.absolute(4), Some(4)); @@ -88,7 +95,6 @@ mod tests { fn filtered_projection_basics() { let proj = Projection::Filtered(Arc::from([1, 3, 7].as_slice())); assert_eq!(proj.len(), 3); - assert!(!proj.is_empty()); assert!(!proj.is_full()); assert_eq!(proj.absolute(0), Some(1)); assert_eq!(proj.absolute(1), Some(3)); @@ -99,11 +105,9 @@ mod tests { #[test] fn empty_projections() { let full = Projection::Full { len: 0 }; - assert!(full.is_empty()); assert_eq!(full.iter().count(), 0); let filtered = Projection::Filtered(Arc::from([].as_slice())); - assert!(filtered.is_empty()); assert_eq!(filtered.iter().count(), 0); } #[test] @@ -159,6 +163,24 @@ mod tests { assert_eq!(filtered.relative(0), None); } + #[test] + fn full_projection_relative_range_intersecting() { + let proj = Projection::Full { len: 5 }; + assert_eq!(proj.relative_range_intersecting(2..5), Some(2..5)); + assert_eq!(proj.relative_range_intersecting(2..99), Some(2..5)); + assert_eq!(proj.relative_range_intersecting(5..8), None); + assert_eq!(proj.relative_range_intersecting(3..3), None); + } + + #[test] + fn filtered_projection_relative_range_intersecting() { + let proj = Projection::Filtered(Arc::from([1, 3, 4, 7].as_slice())); + assert_eq!(proj.relative_range_intersecting(2..6), Some(1..3)); + assert_eq!(proj.relative_range_intersecting(1..8), Some(0..4)); + assert_eq!(proj.relative_range_intersecting(5..6), None); + assert_eq!(proj.relative_range_intersecting(3..3), None); + } + #[test] fn relative_absolute_round_trip() { let proj = Projection::Filtered(Arc::from([2, 5, 8].as_slice())); diff --git a/libmsa/src/translation.rs b/libmsa/src/translation.rs index a7fbfcc..4213338 100644 --- a/libmsa/src/translation.rs +++ b/libmsa/src/translation.rs @@ -1,14 +1,36 @@ use std::ops::Range; -use crate::Alignment; -use crate::alignment_type::AlignmentType; -use crate::data::{AlignmentData, RawSequence}; -use crate::error::AlignmentError; -use crate::metrics::{ - ColumnSummary, ConsensusMethod, counted_translated_columns_positions, - counted_translated_columns_range, summaries_from_columns, +use rayon::prelude::*; + +use crate::{ + Alignment, + alignment_type::AlignmentType, + error::AlignmentError, + metrics::{ + ColumnSummary, ConsensusMethod, counted_translated_columns_range, summaries_from_columns, + }, }; +const NUCLEOTIDE_INDEX_TABLE: [u8; 256] = build_nucleotide_index_table(); + +const fn build_nucleotide_index_table() -> [u8; 256] { + // 4 is invalid, only maps valid nts to 0-3 + let mut table = [4; 256]; + + table[b'A' as usize] = 0; + table[b'a' as usize] = 0; + table[b'T' as usize] = 1; + table[b't' as usize] = 1; + table[b'U' as usize] = 1; + table[b'u' as usize] = 1; + table[b'C' as usize] = 2; + table[b'c' as usize] = 2; + table[b'G' as usize] = 3; + table[b'g' as usize] = 3; + + table +} + /// Reading frames for translating. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ReadingFrame { @@ -60,6 +82,15 @@ impl ReadingFrame { ((nucleotide_length - 1 - offset) / 3) + 1 } + + /// Returns the number of complete three-nucleotide codons for this frame. + /// + /// Unlike [`translated_length`](Self::translated_length), this excludes the + /// incomplete terminal codon when the remaining nucleotides after the frame + /// offset are not divisible by three. + pub const fn complete_codons(self, nucleotide_len: usize) -> usize { + nucleotide_len.saturating_sub(self.offset()) / 3 + } } impl std::fmt::Display for ReadingFrame { @@ -160,22 +191,6 @@ impl<'a> TranslatedAlignment<'a> { self.translated_column_count } - /// Returns the translated view for one visible row by absolute row id. - /// - /// The row id is resolved against the source alignment's current row - /// projection, not against the translated view itself. Returns `None` when - /// the row is not visible in the source alignment. - pub fn sequence_by_absolute(&self, absolute_row: usize) -> Option> { - let _relative = self.source.relative_row_id(absolute_row)?; - let sequence = self.source.data.sequences.get(absolute_row)?; - Some(TranslatedSequenceView { - data: sequence.sequence(), - frame: self.frame, - table: self.table, - translated_len: self.translated_column_count, - }) - } - /// Returns a [`TranslatedSequenceView`] for the absolute row but projected /// through this alignment's current column projection. /// @@ -188,7 +203,7 @@ impl<'a> TranslatedAlignment<'a> { pub fn project_absolute_row(&self, abs_row: usize) -> Option> { let sequence = self.source.data.sequences.get(abs_row)?; Some(TranslatedSequenceView { - data: sequence.sequence(), + data: &sequence.sequence, frame: self.frame, table: self.table, translated_len: self.translated_column_count, @@ -207,57 +222,29 @@ impl<'a> TranslatedAlignment<'a> { /// Returns any [`AlignmentError`] encountered while materialising the /// translated rows into a concrete alignment. pub fn to_alignment(&self) -> Result { - let translated = self - .source - .absolute_row_ids() - .map(|absolute_row| { + let translated = (0..self.source.rows.len()) + .into_par_iter() + .map(|relative_row| { + let absolute_row = self + .source + .rows + .absolute(relative_row) + .expect("out of bounds relative row index"); let sequence = self.source.data.sequences.get(absolute_row).ok_or( AlignmentError::RowOutOfBounds { index: absolute_row, row_count: self.source.data.sequences.len(), }, )?; - Ok(RawSequence { - id: sequence.id().to_string(), - sequence: translate_sequence(sequence.sequence(), self.frame, &self.table), + + Ok(crate::RawSequence { + id: sequence.id.to_string(), + sequence: translate_sequence(&sequence.sequence, self.frame, &self.table), }) }) .collect::, _>>()?; - let data = AlignmentData::from_raw(translated)?; - Ok(Alignment::from_data(data, AlignmentType::Protein)) - } - - /// Returns a summary for each requested protein column. - /// - /// The returned positions use protein-column coordinates from the translated - /// view. Consensus, conservation, and gap fraction are calculated from the - /// visible rows in the source alignment with this view's reading frame and - /// translation table. - /// - /// # Errors - /// - /// [`AlignmentError::ColumnOutOfBounds`] if any value in `positions` is not a - /// valid protein-column index in the translated view. - pub fn column_summaries_positions( - &self, - positions: &[usize], - method: ConsensusMethod, - ) -> Result, AlignmentError> { - let columns = counted_translated_columns_positions( - &self.source.data, - &self.source.rows, - positions, - self.frame, - &self.table, - )?; - let mut rng = rand::rng(); - Ok(summaries_from_columns( - &columns, - method, - AlignmentType::Protein.conservation_alphabet_size(), - &mut rng, - )) + Alignment::new_with_type(translated, AlignmentType::Protein) } /// Returns summary for each protein column in `range`. @@ -294,32 +281,6 @@ impl<'a> TranslatedAlignment<'a> { )) } - /// Returns the translated consensus byte for each protein column in `range`. - /// - /// The returned positions use protein-column coordinates from the translated - /// view. Consensus is calculated from the visible rows in the source alignment - /// with this view's reading frame and translation table. - /// - /// # Errors - /// - /// [`AlignmentError::EmptyRange`] if `range` is empty. - /// - /// [`AlignmentError::ColumnOutOfBounds`] if `range.end` is greater than - /// the translated width of this view. - pub fn consensus_range( - &self, - range: Range, - method: ConsensusMethod, - ) -> Result)>, AlignmentError> { - let summaries = self.column_summaries_range(range, method)?; - Ok(summaries - .into_iter() - .map(|summary| (summary.position, summary.consensus)) - .collect()) - } -} - -impl<'a> TranslatedAlignment<'a> { pub(crate) fn new( source: &'a Alignment, frame: ReadingFrame, @@ -430,37 +391,32 @@ pub(crate) fn normalise_nucleotide(byte: u8) -> Option { } } -pub(crate) fn translate_sequence( - sequence: &[u8], - frame: ReadingFrame, - table: &TranslationTable, -) -> Vec { - let mut translated = Vec::with_capacity(translated_length(sequence.len(), frame)); - - for codon_start in (frame.offset()..sequence.len()).step_by(3) { - let codon = [ - sequence - .get(codon_start) - .and_then(|&byte| normalise_nucleotide(byte)), - sequence - .get(codon_start + 1) - .and_then(|&byte| normalise_nucleotide(byte)), - sequence - .get(codon_start + 2) - .and_then(|&byte| normalise_nucleotide(byte)), - ]; +fn translate_sequence(sequence: &[u8], frame: ReadingFrame, table: &TranslationTable) -> Vec { + let offset = frame.offset(); + if sequence.len() <= offset { + return Vec::new(); + } - let amino_acid = match codon { - [Some(first), Some(second), Some(third)] => { - table.translate_codon([first, second, third]) - } - _ => b'X', - }; + let translated_len = translated_length(sequence.len(), frame); + let complete_codons = frame.complete_codons(sequence.len()); + let complete_end = offset + (complete_codons * 3); + let has_incomplete_terminal_codon = complete_codons < translated_len; - translated.push(amino_acid); - } + sequence[offset..complete_end] + .chunks_exact(3) + .map(|codon| { + let first = nucleotide_index(codon[0]); + let second = nucleotide_index(codon[1]); + let third = nucleotide_index(codon[2]); - translated + if first == 4 || second == 4 || third == 4 { + b'X' + } else { + table.codons[usize::from(first)][usize::from(second)][usize::from(third)] + } + }) + .chain(has_incomplete_terminal_codon.then_some(b'X')) + .collect() } pub(crate) fn translated_byte_at( @@ -493,13 +449,15 @@ pub(crate) fn translated_length(sequence_len: usize, frame: ReadingFrame) -> usi frame.translated_length(sequence_len) } +#[inline] +fn nucleotide_index(base: u8) -> u8 { + NUCLEOTIDE_INDEX_TABLE[base as usize] +} + fn index_nucleotide(base: u8) -> Option { - match base { - b'A' => Some(0), - b'T' => Some(1), - b'C' => Some(2), - b'G' => Some(3), - _ => None, + match nucleotide_index(base) { + 4 => None, + index => Some(index as usize), } } @@ -712,6 +670,23 @@ mod reading_frame_tests { assert_eq!(ReadingFrame::Frame2.translated_length(2), 1); assert_eq!(ReadingFrame::Frame3.translated_length(2), 0); } + + #[test] + fn complete_codons_excludes_incomplete_terminal() { + // 9 nucleotides, frame 1: 9 / 3 = 3 complete codons. + assert_eq!(ReadingFrame::Frame1.complete_codons(9), 3); + // 10 nucleotides, frame 1: 10 / 3 = 3 (drops the leftover). + assert_eq!(ReadingFrame::Frame1.complete_codons(10), 3); + // translated_length would give 4 here (includes the incomplete). + assert_eq!(ReadingFrame::Frame1.translated_length(10), 4); + + // Frame 2, 9 nucleotides: (9 - 1) / 3 = 2 complete codons. + assert_eq!(ReadingFrame::Frame2.complete_codons(9), 2); + // Frame 3, 2 nucleotides: (2 - 2) / 3 = 0. + assert_eq!(ReadingFrame::Frame3.complete_codons(2), 0); + // Edge: empty sequence. + assert_eq!(ReadingFrame::Frame1.complete_codons(0), 0); + } } #[cfg(test)] @@ -739,9 +714,9 @@ mod translated_alignment_tests { let frame2 = alignment.translated(ReadingFrame::Frame2).unwrap(); let frame3 = alignment.translated(ReadingFrame::Frame3).unwrap(); - let frame1_sequence = frame1.sequence_by_absolute(0).unwrap(); - let frame2_sequence = frame2.sequence_by_absolute(0).unwrap(); - let frame3_sequence = frame3.sequence_by_absolute(0).unwrap(); + let frame1_sequence = frame1.project_absolute_row(0).unwrap(); + let frame2_sequence = frame2.project_absolute_row(0).unwrap(); + let frame3_sequence = frame3.project_absolute_row(0).unwrap(); assert_eq!(frame1_sequence.byte_at(0), Some(b'M')); assert_eq!(frame1_sequence.byte_at(1), Some(b'P')); @@ -778,30 +753,6 @@ mod translated_alignment_tests { ); } - #[test] - fn translated_sequence_by_absolute_returns_visible_row() { - let alignment = Alignment::new_with_type( - vec![ - raw("s1", b"ATGAAA"), - raw("s2", b"TTTCCC"), - raw("s3", b"GGGAAA"), - ], - AlignmentType::Dna, - ) - .unwrap(); - let filtered = alignment - .filter() - .unwrap() - .without_rows([1]) - .apply() - .unwrap(); - let translated = filtered.translated(ReadingFrame::Frame1).unwrap(); - - let sequence = translated.sequence_by_absolute(2).unwrap(); - assert_eq!(sequence.byte_at(0), Some(b'G')); - assert!(translated.sequence_by_absolute(1).is_none()); - } - #[test] fn translated_alignment_builds_protein_alignment() { let alignment = Alignment::new_with_type( @@ -894,9 +845,6 @@ mod translated_alignment_tests { let translated = filtered.translated(ReadingFrame::Frame1).unwrap(); let materialised = translated.to_alignment().unwrap(); - assert!(translated.sequence_by_absolute(1).is_some()); - assert!(translated.sequence_by_absolute(0).is_none()); - assert!(translated.sequence_by_absolute(2).is_none()); assert_eq!( materialised .sequence(0) @@ -941,7 +889,7 @@ mod translated_alignment_tests { } #[test] - fn translated_alignment_custom_table_is_used_for_consensus() { + fn translated_alignment_custom_table_is_used_for_summaries() { let alignment = Alignment::new_with_type( vec![ raw("s1", b"ATGAAA"), @@ -983,13 +931,11 @@ mod translated_alignment_tests { let translated = alignment .translated_with(ReadingFrame::Frame1, custom) .unwrap(); + let summaries = translated + .column_summaries_range(0..1, ConsensusMethod::MajorityNonGap) + .unwrap(); - assert_eq!( - translated - .consensus_range(0..1, ConsensusMethod::MajorityNonGap) - .unwrap(), - vec![(0, Some(b'Z'))] - ); + assert_eq!(summaries[0].consensus, Some(b'Z')); } #[test] @@ -1011,7 +957,6 @@ mod translated_alignment_tests { .unwrap(); let translated = filtered.translated(ReadingFrame::Frame1).unwrap(); - assert!(translated.sequence_by_absolute(1).is_none()); assert_eq!( translated_bytes(translated.project_absolute_row(1).unwrap(), 2), vec![(0, b'F'), (1, b'P')] @@ -1027,12 +972,14 @@ mod translated_alignment_tests { .unwrap(); let translated = alignment.translated(ReadingFrame::Frame1).unwrap(); - for abs_row in 0..2 { - assert_eq!( - translated_bytes(translated.sequence_by_absolute(abs_row).unwrap(), 2), - translated_bytes(translated.project_absolute_row(abs_row).unwrap(), 2) - ); - } + assert_eq!( + translated_bytes(translated.project_absolute_row(0).unwrap(), 2), + vec![(0, b'M'), (1, b'K')] + ); + assert_eq!( + translated_bytes(translated.project_absolute_row(1).unwrap(), 2), + vec![(0, b'F'), (1, b'P')] + ); } #[test] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..455c820 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +imports_granularity = "Crate" +group_imports = "StdExternalCrate" diff --git a/salti/Cargo.toml b/salti/Cargo.toml index 300b08d..791c93e 100644 --- a/salti/Cargo.toml +++ b/salti/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "salti" -version = "0.8.0" +version = "0.9.0" edition = "2024" authors = ["Samuel Sims"] description = "A modern, fast, multiple sequence alignment browser - built for the terminal. " @@ -29,6 +29,7 @@ tracing = "0.1.41" tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } human-panic = "2" +noodles-gff = "0.56.0" [dev-dependencies] insta = "1.46.3" diff --git a/salti/src/app.rs b/salti/src/app.rs index fa4bd5c..9de1333 100644 --- a/salti/src/app.rs +++ b/salti/src/app.rs @@ -1,9 +1,8 @@ -use std::{env, time::Duration}; +use std::{env, path::Path, time::Duration}; use anyhow::{Result, format_err}; use crossterm::event::{Event as TermEvent, EventStream, KeyEvent, MouseEvent}; -use ratatui::DefaultTerminal; -use ratatui::layout::Rect; +use ratatui::{DefaultTerminal, layout::Rect}; use tokio::{ sync::mpsc::{UnboundedSender, unbounded_channel}, task::{JoinError, JoinHandle, JoinSet}, @@ -12,19 +11,31 @@ use tokio_stream::StreamExt; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; -use crate::cli::StartupState; -use crate::command::Command; -use crate::core::model::{AlignmentModel, StatsView}; -use crate::core::parser; -use crate::core::stats_cache::{ColumnStatsCache, StatsJobRequest, StatsJobResult}; -use crate::input; -use crate::input::MouseTracker; -use crate::overlay::command_palette::CommandPaletteState; -use crate::ui::layout::{AppLayout, FrameLayout, pinned_section_layout}; -use crate::ui::notification::{Notification, NotificationLevel}; -use crate::ui::render::render; -use crate::ui::ui_state::{LoadingState, UiState}; -use crate::update::UpdateResult; +use crate::{ + cli::StartupState, + command::Command, + core::{ + gff::{self, Gff}, + model::{AlignmentModel, StatsView}, + parser, + stats_cache::{ColumnStatsCache, StatsJobRequest, StatsJobResult}, + }, + input, + input::MouseTracker, + ui::{ + layers::{ + notification::{Notification, NotificationLevel}, + palette::CommandPaletteState, + }, + layout::{ + AlignmentHeaderLayout, AppLayout, FrameLayout, gff_pane_height, pinned_section_layout, + }, + panes::{gff::feature_row_count, local_feature_track::local_feature_row_count}, + render::render, + ui_state::{LoadingState, UiState}, + }, + update::UpdateResult, +}; const RENDER_FPS: f32 = 120.0; @@ -46,6 +57,7 @@ struct AsyncJob { #[derive(Debug)] pub(crate) struct App { alignment: Option, + gff: Option, ui: UiState, mouse_tracker: MouseTracker, stats_cache: ColumnStatsCache, @@ -57,15 +69,22 @@ pub(crate) struct App { layout_area: Rect, frame_layout: FrameLayout, app_layout: AppLayout, + reloaded_nucleotide_phase: usize, } impl App { pub(crate) fn new(startup: StartupState) -> Self { let layout_area = Rect::default(); let frame_layout = FrameLayout::new(layout_area); - let app_layout = AppLayout::new(frame_layout.content_area); + let app_layout = AppLayout::new( + frame_layout.content_area, + // TODO: remove magic number similar to AlignmentHeaderLayout - currently 0 at start since no GFF loaded + 0, + AlignmentHeaderLayout::without_features(), + ); Self { alignment: None, + gff: None, ui: UiState::new(startup), mouse_tracker: MouseTracker::default(), stats_cache: ColumnStatsCache::default(), @@ -77,6 +96,7 @@ impl App { layout_area, frame_layout, app_layout, + reloaded_nucleotide_phase: 0, } } @@ -92,7 +112,7 @@ impl App { height = area.height, "Captured initial terminal size" ); - self.update_layout(area.into()); + self.rebuild_layout(area.into()); } Err(error) => { warn!(error = ?error, "Failed to capture initial terminal size"); @@ -108,9 +128,7 @@ impl App { self.event_tx = Some(event_tx); let mut needs_redraw = true; if Self::startup_update_check_enabled() { - self.execute_commands([Command::CheckForUpdate { - show_success_message: false, - }]); + self.execute_commands([Command::CheckForUpdate]); } else { debug!( env_var = UPDATE_CHECK_ENV_VAR, @@ -123,10 +141,14 @@ impl App { _ = interval.tick() => { if needs_redraw { if let Err(error) = terminal.draw(|frame| { - self.update_layout(frame.area()); + let area = frame.area(); + if area != self.layout_area { + self.rebuild_layout(area); + } render( frame, self.alignment.as_ref(), + self.gff.as_ref(), &self.ui, &self.stats_cache, &self.frame_layout, @@ -142,7 +164,7 @@ impl App { Some(Ok(event)) = events.next() => { match event { TermEvent::Resize(width, height) => { - self.update_layout(Rect::new(0, 0, width, height)); + self.rebuild_layout(Rect::new(0, 0, width, height)); self.extend_stats_if_needed(); } TermEvent::Key(key) => { @@ -233,14 +255,59 @@ impl App { self.start_load_job(input); } - fn update_layout(&mut self, area: Rect) { - if area == self.layout_area { - return; - } - + fn rebuild_layout(&mut self, area: Rect) { self.layout_area = area; self.frame_layout = FrameLayout::new(area); - self.app_layout = AppLayout::new(self.frame_layout.content_area); + // TODO: revist this as feels clunky + // hides the gff pane if we dont have one loaded + // if loaded the height is dynamic to the number of rows the features spill on to + let gff_height = self.gff.as_ref().map_or(0, |gff| { + let Some(alignment) = self.alignment.as_ref() else { + return 0; + }; + // create a temp applayout with a gff height of 1 to get a value for width + let probe_layout = AppLayout::new( + self.frame_layout.content_area, + gff_pane_height(1), + AlignmentHeaderLayout::without_features(), + ); + let width = usize::from(probe_layout.gff_pane_rows.width); + gff_pane_height(feature_row_count(gff, alignment, width).max(1)) + }); + let local_feature_rows = match (self.gff.as_ref(), self.alignment.as_ref()) { + (Some(gff), Some(alignment)) => { + let probe_layout = AppLayout::new( + self.frame_layout.content_area, + gff_height, + AlignmentHeaderLayout::without_features(), + ); + let visible_width = probe_layout.alignment_pane.width.saturating_sub(2) as usize; + let col_start = self + .ui + .viewport + .offsets + .cols + .min(alignment.view().column_count()); + let col_end = col_start + .saturating_add(visible_width) + .min(alignment.view().column_count()); + u16::try_from(local_feature_row_count( + gff, + alignment, + &(col_start..col_end), + )) + .unwrap_or(u16::MAX) + } + (None, _) | (_, None) => 0, + }; + let alignment_header = if local_feature_rows == 0 { + AlignmentHeaderLayout::without_features() + } else { + AlignmentHeaderLayout::with_features(local_feature_rows) + }; + // set the real layout once we know the height of the gff + self.app_layout = + AppLayout::new(self.frame_layout.content_area, gff_height, alignment_header); let visible_width = self.app_layout.alignment_pane.width.saturating_sub(2) as usize; let available_sequence_rows = self.app_layout.alignment_pane_sequence_rows.height as usize; @@ -291,6 +358,7 @@ impl App { let commands = input::handle_mouse_event( &mut self.mouse_tracker, self.alignment.as_ref(), + self.gff.as_ref(), &mut self.ui, &self.frame_layout, &self.app_layout, @@ -339,10 +407,10 @@ impl App { self.open_command_palette(); } Command::CloseOverlay => { - self.ui.overlay.close(); + self.ui.layers.close_active(); } Command::ToggleMinimap => { - self.ui.overlay.toggle_minimap(); + self.ui.layers.toggle_minimap(); } Command::SetTheme(theme_id) => { self.ui.set_theme(theme_id); @@ -354,16 +422,38 @@ impl App { self.clear_mouse_selection(); self.start_load_job(input); } - Command::CheckForUpdate { - show_success_message, - } => { - self.spawn_update_check(show_success_message); + Command::LoadGff { path } => match gff::parse_gff(Path::new(&path)) { + Ok(model) => { + self.gff = Some(model); + self.ui.gff_pane = Default::default(); + self.rebuild_layout(self.layout_area); + self.show_info(format!("Loaded GFF file: {path}")); + } + Err(error) => { + return Err(error); + } + }, + Command::CheckForUpdate => { + self.spawn_update_check(false); + } + Command::CheckForUpdateAndNotify => { + self.spawn_update_check(true); } Command::ScrollDown { amount } => self.ui.viewport.scroll_down(amount), Command::ScrollUp { amount } => self.ui.viewport.scroll_up(amount), - Command::ScrollLeft { amount } => self.ui.viewport.scroll_left(amount), - Command::ScrollRight { amount } => self.ui.viewport.scroll_right(amount), + Command::ScrollLeft { amount } => { + self.ui.viewport.scroll_left(amount); + if self.layout_needs_rebuild() { + self.rebuild_layout(self.layout_area); + } + } + Command::ScrollRight { amount } => { + self.ui.viewport.scroll_right(amount); + if self.layout_needs_rebuild() { + self.rebuild_layout(self.layout_area); + } + } Command::ScrollNamesLeft { amount } => self.ui.viewport.scroll_names_left(amount), Command::ScrollNamesRight { amount } => self.ui.viewport.scroll_names_right(amount), @@ -374,6 +464,9 @@ impl App { .is_some_and(|alignment| relative_col < alignment.view().column_count()); if has_column { self.ui.viewport.jump_to_position(relative_col); + if self.layout_needs_rebuild() { + self.rebuild_layout(self.layout_area); + } } } Command::JumpToSequence(abs_row) => { @@ -394,6 +487,9 @@ impl App { .is_some_and(|alignment| alignment.view().column_count() > 0); if has_columns { self.ui.viewport.jump_to_position(0); + if self.layout_needs_rebuild() { + self.rebuild_layout(self.layout_area); + } } } Command::JumpToEnd => { @@ -403,6 +499,9 @@ impl App { .and_then(|alignment| alignment.view().column_count().checked_sub(1)); if let Some(last_col) = last_col { self.ui.viewport.jump_to_position(last_col); + if self.layout_needs_rebuild() { + self.rebuild_layout(self.layout_area); + } } } @@ -447,6 +546,17 @@ impl App { self.on_view_rebuilt(); return Ok(()); } + Command::SetConstantFilter(min_constant_fraction) => { + let alignment = self.alignment_mut()?; + if min_constant_fraction.is_some() && alignment.translation().is_some() { + return Err(format_err!( + "filter-constant is unavailable while translation is active" + )); + } + alignment.set_constant_filter(min_constant_fraction)?; + self.on_view_rebuilt(); + return Ok(()); + } Command::ClearFilter => { self.alignment_mut()?.clear_filter()?; self.on_view_rebuilt(); @@ -462,17 +572,31 @@ impl App { let alignment = self.alignment_mut()?; if alignment.translation().is_none() && alignment.filter().has_column_filter() { return Err(format_err!( - "translation is unavailable while filter-gaps is active" + "translation is unavailable while a column filter is active" )); } alignment.toggle_translation_view()?; self.invalidate_all_stats(); return Ok(()); } + Command::ReloadAsProtein { frame } => { + let viewport_target = self.reload_as_protein_viewport_target(frame); + self.alignment_mut()?.toggle_reload_as_protein(frame)?; + self.clear_mouse_selection(); + self.rebuild_layout(self.layout_area); + self.jump_to_reloaded_viewport_target(viewport_target); + self.invalidate_all_stats(); + return Ok(()); + } Command::SetTranslationFrame(frame) => { let alignment = self.alignment_mut()?; let was_enabled = alignment.translation().is_some(); + let was_reloaded = alignment.is_reloaded_as_protein(); alignment.set_translation_frame(frame)?; + if was_reloaded { + self.on_view_rebuilt(); + return Ok(()); + } if was_enabled { self.invalidate_translated_stats(); } @@ -497,16 +621,58 @@ impl App { let palette = self .alignment .as_ref() - .map(CommandPaletteState::from_alignment) + .map(|alignment| CommandPaletteState::from_alignment(alignment, self.gff.as_ref())) .unwrap_or_else(CommandPaletteState::empty); - self.ui.overlay.open_palette(palette); + self.ui.layers.open_palette(palette); } fn on_view_rebuilt(&mut self) { - self.refresh_viewport_bounds(); + self.rebuild_layout(self.layout_area); self.invalidate_all_stats(); } + fn reload_as_protein_viewport_target( + &mut self, + frame: Option, + ) -> Option { + let alignment = self.alignment.as_ref()?; + let frame = frame.unwrap_or(alignment.translation_frame()); + let relative_col = self.ui.viewport.window().col_range.start; + let absolute_col = alignment.view().absolute_column_id(relative_col)?; + + if alignment.is_reloaded_as_protein() { + let nucleotide_col = absolute_col + .checked_mul(3) + .and_then(|scaled| frame.offset().checked_add(scaled))? + .saturating_add(self.reloaded_nucleotide_phase); + return Some(nucleotide_col); + } + + self.reloaded_nucleotide_phase = match absolute_col.checked_sub(frame.offset()) { + Some(offset_col) => offset_col % 3, + None => 0, + }; + Some(frame.protein_col(absolute_col).unwrap_or(0)) + } + + fn jump_to_reloaded_viewport_target(&mut self, target_absolute_col: Option) { + let Some(target_absolute_col) = target_absolute_col else { + return; + }; + let Some(alignment) = self.alignment.as_ref() else { + return; + }; + let Some(relative_col) = + nearest_visible_relative_column(alignment.view(), target_absolute_col) + else { + return; + }; + self.ui.viewport.jump_to_position(relative_col); + if self.layout_needs_rebuild() { + self.rebuild_layout(self.layout_area); + } + } + fn clear_mouse_selection(&mut self) { self.ui.selection = None; self.mouse_tracker.clear_anchors(); @@ -530,6 +696,26 @@ impl App { ); } + fn layout_needs_rebuild(&self) -> bool { + let (Some(gff), Some(alignment)) = (self.gff.as_ref(), self.alignment.as_ref()) else { + return false; + }; + + let visible_width = self.app_layout.alignment_pane.width.saturating_sub(2) as usize; + let col_start = self.ui.viewport.offsets.cols; + let col_end = col_start + .saturating_add(visible_width) + .min(alignment.view().column_count()); + let local_feature_rows = u16::try_from(local_feature_row_count( + gff, + alignment, + &(col_start..col_end), + )) + .unwrap_or(u16::MAX); + + local_feature_rows != self.app_layout.alignment_header.local_feature_rows + } + fn alignment_mut(&mut self) -> Result<&mut AlignmentModel> { self.alignment .as_mut() @@ -693,11 +879,28 @@ impl App { } } +fn nearest_visible_relative_column( + view: &libmsa::Alignment, + target_absolute_col: usize, +) -> Option { + if let Some(relative_col) = view.relative_column_id(target_absolute_col) { + return Some(relative_col); + } + + let mut previous_relative_col = None; + for (relative_col, absolute_col) in view.absolute_column_ids().enumerate() { + if target_absolute_col <= absolute_col { + return Some(relative_col); + } + previous_relative_col = Some(relative_col); + } + + previous_relative_col +} + #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind}; - use crate::ui::ui_state::MouseSelection; fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { @@ -713,105 +916,67 @@ mod tests { initial_position: 0, }; let mut app = App::new(startup); - let alignment = libmsa::Alignment::new(sequences).expect("alignment should load"); - let model = AlignmentModel::new(alignment).expect("alignment model should build"); + let alignment = libmsa::Alignment::new(sequences).unwrap(); + let model = AlignmentModel::new(alignment).unwrap(); app.stats_cache.init(model.view().column_count()); app.alignment = Some(model); app.ui.meta.loading_state = LoadingState::Loaded; app.refresh_viewport_bounds(); - app.update_layout(Rect::new(0, 0, 40, 12)); + app.rebuild_layout(Rect::new(0, 0, 40, 12)); app } - fn left_mouse_event( - kind: MouseEventKind, - area: Rect, - column_offset: u16, - row_offset: u16, - ) -> MouseEvent { - MouseEvent { - kind, - column: area.x + column_offset, - row: area.y + row_offset, - modifiers: KeyModifiers::empty(), + fn gff_with_overlapping_features() -> Gff { + Gff { + features: vec![ + gff::Feature { + name: "gene1".to_string(), + kind: gff::FeatureType::Gene, + range: 0..10, + strand: gff::Strand::Forward, + }, + gff::Feature { + name: "gene2".to_string(), + kind: gff::FeatureType::Gene, + range: 0..10, + strand: gff::Strand::Forward, + }, + ], } } - #[test] - fn translated_click_selects_a_full_codon_span() { - let mut app = - app_with_alignment(vec![raw("row1", b"ATGAAATTT"), raw("row2", b"ATGAAATTT")]); - app.alignment - .as_mut() - .unwrap() - .set_translation_frame(libmsa::ReadingFrame::Frame1) - .expect("setting translation frame should succeed"); - app.alignment - .as_mut() - .unwrap() - .toggle_translation_view() - .expect("translation should enable"); - - let area = app.app_layout.alignment_pane_sequence_rows; - app.handle_mouse_event(left_mouse_event( - MouseEventKind::Down(MouseButton::Left), - area, - 1, - 0, - )); + #[tokio::test(flavor = "current_thread")] + async fn horizontal_scroll_updates_layout_local_feature_stacked() { + let sequence = vec![b'A'; 120]; + let mut app = app_with_alignment(vec![raw("seq1", &sequence)]); + app.gff = Some(gff_with_overlapping_features()); + app.rebuild_layout(app.layout_area); + assert_eq!(app.app_layout.alignment_header.local_feature_rows, 2); + + app.execute_commands([Command::ScrollRight { amount: 50 }]); - let selection = app.ui.selection.expect("selection should be created"); - assert_eq!(selection.sequence_id, 0); - assert_eq!(selection.column, 0); - assert_eq!(selection.end_column, 2); + assert_eq!(app.ui.viewport.offsets.cols, 50); + assert_eq!(app.app_layout.alignment_header.local_feature_rows, 1); } - #[test] - fn translated_drag_extends_selection_in_whole_codons() { - let mut app = - app_with_alignment(vec![raw("row1", b"ATGAAATTT"), raw("row2", b"ATGAAATTT")]); - app.alignment - .as_mut() - .unwrap() - .set_translation_frame(libmsa::ReadingFrame::Frame1) - .expect("setting translation frame should succeed"); - app.alignment - .as_mut() - .unwrap() - .toggle_translation_view() - .expect("translation should enable"); - - let area = app.app_layout.alignment_pane_sequence_rows; - app.handle_mouse_event(left_mouse_event( - MouseEventKind::Down(MouseButton::Left), - area, - 1, - 0, - )); - app.handle_mouse_event(left_mouse_event( - MouseEventKind::Drag(MouseButton::Left), - area, - 7, - 0, - )); - app.handle_mouse_event(left_mouse_event( - MouseEventKind::Up(MouseButton::Left), - area, - 7, - 0, - )); + #[tokio::test(flavor = "current_thread")] + async fn jump_to_position_updates_layout_local_feature_stacked() { + let sequence = vec![b'A'; 120]; + let mut app = app_with_alignment(vec![raw("seq1", &sequence)]); + app.gff = Some(gff_with_overlapping_features()); + app.rebuild_layout(app.layout_area); + assert_eq!(app.app_layout.alignment_header.local_feature_rows, 2); + + app.execute_commands([Command::JumpToPosition(50)]); - let selection = app.ui.selection.expect("selection should be created"); - assert_eq!(selection.sequence_id, 0); - assert_eq!(selection.column, 0); - assert_eq!(selection.end_sequence_id, 0); - assert_eq!(selection.end_column, 8); + assert_eq!(app.ui.viewport.offsets.cols, 50); + assert_eq!(app.app_layout.alignment_header.local_feature_rows, 1); } #[tokio::test(flavor = "current_thread")] - async fn toggling_translation_preserves_the_stored_nucleotide_selection() { + async fn translation_toggle_keeps_selection() { let mut app = - app_with_alignment(vec![raw("row1", b"ATGAAATTT"), raw("row2", b"ATGAAATTT")]); + app_with_alignment(vec![raw("seq1", b"ATGAAATTT"), raw("seq2", b"ATGAAATTT")]); let selection = MouseSelection { sequence_id: 0, column: 4, @@ -828,17 +993,67 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn filter_gaps_shows_notification_when_translation_is_active() { + async fn reload_as_protein_clears_selection() { + let mut app = + app_with_alignment(vec![raw("seq1", b"ATGAAATTT"), raw("seq2", b"ATGAAATTT")]); + app.ui.selection = Some(MouseSelection { + sequence_id: 0, + column: 0, + end_sequence_id: 0, + end_column: 2, + }); + + app.execute_commands([Command::ReloadAsProtein { frame: None }]); + + assert!(app.alignment.as_ref().unwrap().is_reloaded_as_protein()); + assert_eq!( + app.alignment.as_ref().unwrap().base().active_type(), + libmsa::AlignmentType::Protein + ); + assert_eq!(app.ui.selection, None); + + app.execute_commands([Command::ReloadAsProtein { frame: None }]); + + assert!(!app.alignment.as_ref().unwrap().is_reloaded_as_protein()); + assert_eq!( + app.alignment.as_ref().unwrap().base().active_type(), + libmsa::AlignmentType::Dna + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn reload_as_protein_keeps_locus_from_nt() { + let sequence = vec![b'C'; 360]; + let mut app = app_with_alignment(vec![raw("seq1", &sequence)]); + app.ui.viewport.jump_to_position(200); + + app.execute_commands([Command::ReloadAsProtein { frame: None }]); + + assert_eq!(app.ui.viewport.window().col_range.start, 66); + } + + #[tokio::test(flavor = "current_thread")] + async fn reload_as_dna_keeps_locus_from_aa() { + let sequence = vec![b'C'; 360]; + let mut app = app_with_alignment(vec![raw("seq1", &sequence)]); + app.ui.viewport.jump_to_position(200); + + app.execute_commands([Command::ReloadAsProtein { frame: None }]); + app.ui.viewport.jump_to_position(70); + + app.execute_commands([Command::ReloadAsProtein { frame: None }]); + + assert_eq!(app.ui.viewport.window().col_range.start, 212); + } + + #[tokio::test(flavor = "current_thread")] + async fn gap_filter_blocked_during_translation() { let mut app = - app_with_alignment(vec![raw("row1", b"ATGAAATTT"), raw("row2", b"ATGAAATTT")]); + app_with_alignment(vec![raw("seq1", b"ATGAAATTT"), raw("seq2", b"ATGAAATTT")]); app.execute_commands([Command::ToggleTranslationView]); app.execute_commands([Command::SetGapFilter(Some(0.25))]); - let notification = app - .ui - .notification - .as_ref() - .expect("notification should be created"); + let notification = app.ui.notification.as_ref().unwrap(); assert_eq!( notification.message, "filter-gaps is unavailable while translation is active" @@ -846,29 +1061,42 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn translation_shows_notification_when_gap_filter_is_active() { - let mut app = app_with_alignment(vec![raw("row1", b"ATG---"), raw("row2", b"ATG---")]); - app.execute_commands([Command::SetGapFilter(Some(0.0))]); + async fn constant_filter_blocked_during_translation() { + let mut app = + app_with_alignment(vec![raw("seq1", b"ATGAAATTT"), raw("seq2", b"ATGAAATTT")]); app.execute_commands([Command::ToggleTranslationView]); + app.execute_commands([Command::SetConstantFilter(Some(0.9))]); - let notification = app - .ui - .notification - .as_ref() - .expect("notification should be created"); + let notification = app.ui.notification.as_ref().unwrap(); assert_eq!( notification.message, - "translation is unavailable while filter-gaps is active" + "filter-constant is unavailable while translation is active" ); } #[tokio::test(flavor = "current_thread")] - async fn key_events_are_forwarded_to_command_execution() { - let mut app = app_with_alignment(vec![raw("row1", b"ACGT"), raw("row2", b"ACGT")]); - let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE); + async fn translation_blocked_by_gap_filter() { + let mut app = app_with_alignment(vec![raw("seq1", b"ATG---"), raw("seq2", b"ATG---")]); + app.execute_commands([Command::SetGapFilter(Some(0.0))]); + app.execute_commands([Command::ToggleTranslationView]); - app.handle_key_event(key); + let notification = app.ui.notification.as_ref().unwrap(); + assert_eq!( + notification.message, + "translation is unavailable while a column filter is active" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn translation_blocked_by_constant_filter() { + let mut app = app_with_alignment(vec![raw("seq1", b"ATGAAA"), raw("seq2", b"ATGAAA")]); + app.execute_commands([Command::SetConstantFilter(Some(1.0))]); + app.execute_commands([Command::ToggleTranslationView]); - assert!(app.should_quit); + let notification = app.ui.notification.as_ref().unwrap(); + assert_eq!( + notification.message, + "translation is unavailable while a column filter is active" + ); } } diff --git a/salti/src/command.rs b/salti/src/command.rs index 64dc32b..ac3206c 100644 --- a/salti/src/command.rs +++ b/salti/src/command.rs @@ -1,6 +1,6 @@ -use crate::config::theme::ThemeId; -use crate::core::model::DiffMode; -use crate::ui::notification::Notification; +use crate::{ + config::theme::ThemeId, core::model::DiffMode, ui::layers::notification::Notification, +}; #[derive(Debug, Clone, PartialEq)] pub enum Command { @@ -11,7 +11,8 @@ pub enum Command { SetTheme(ThemeId), ShowNotification(Notification), LoadFile { input: String }, - CheckForUpdate { show_success_message: bool }, + CheckForUpdate, + CheckForUpdateAndNotify, ScrollDown { amount: usize }, ScrollUp { amount: usize }, ScrollLeft { amount: usize }, @@ -24,6 +25,7 @@ pub enum Command { JumpToEnd, SetFilter(String), SetGapFilter(Option), + SetConstantFilter(Option), ClearFilter, PinSequence(usize), UnpinSequence(usize), @@ -34,4 +36,6 @@ pub enum Command { SetTranslationFrame(libmsa::ReadingFrame), SetDiffMode(DiffMode), ToggleTranslationView, + ReloadAsProtein { frame: Option }, + LoadGff { path: String }, } diff --git a/salti/src/config/keybindings.rs b/salti/src/config/keybindings.rs index c613242..f9b889d 100644 --- a/salti/src/config/keybindings.rs +++ b/salti/src/config/keybindings.rs @@ -29,6 +29,30 @@ const KEY_BINDINGS: &[Binding] = &[ action: Command::ToggleTranslationView, help: "Toggle NT to AA translation view", }, + Binding { + code: KeyCode::Char('T'), + modifiers: KeyModifiers::SHIFT, + action: Command::ReloadAsProtein { frame: None }, + help: "Reload alignment as protein", + }, + Binding { + code: KeyCode::Char('1'), + modifiers: KeyModifiers::ALT, + action: Command::SetTranslationFrame(libmsa::ReadingFrame::Frame1), + help: "Set translation frame 1", + }, + Binding { + code: KeyCode::Char('2'), + modifiers: KeyModifiers::ALT, + action: Command::SetTranslationFrame(libmsa::ReadingFrame::Frame2), + help: "Set translation frame 2", + }, + Binding { + code: KeyCode::Char('3'), + modifiers: KeyModifiers::ALT, + action: Command::SetTranslationFrame(libmsa::ReadingFrame::Frame3), + help: "Set translation frame 3", + }, Binding { code: KeyCode::Char('m'), modifiers: KeyModifiers::NONE, diff --git a/salti/src/core/codon.rs b/salti/src/core/codon.rs new file mode 100644 index 0000000..3c6c706 --- /dev/null +++ b/salti/src/core/codon.rs @@ -0,0 +1,225 @@ +use std::ops::Range; + +use libmsa::ReadingFrame; + +/// A single codon visible in the current viewport. +/// +/// All fields use absolute nucleotide column coordinates except `protein_col` +/// which is a protein-space index. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VisibleCodon { + /// Protein-space column index for this codon. + pub protein_col: usize, + /// Absolute nucleotide column where the codon begins. + pub nuc_start: usize, + /// Absolute nucleotide column that displays the amino-acid letter + /// (the centre cell of the three-wide codon span). + pub centre: usize, +} + +/// Context for the translation visual overlay. +/// +/// Computed once per render frame from the active reading frame and the +/// alignment's nucleotide column count, then threaded through the rendering +/// pipeline. All methods operate in nucleotide-space viewport coordinates. +#[derive(Debug, Clone, Copy)] +pub struct TranslationOverlay { + /// Active reading frame for the overlay. + pub frame: ReadingFrame, + /// Total nucleotide columns in the current alignment view. + pub nucleotide_len: usize, +} + +impl TranslationOverlay { + /// Protein column range overlapping a nucleotide viewport range. + pub fn visible_protein_range(&self, nuc_range: &Range) -> Option> { + visible_protein_range(nuc_range, self.frame, self.nucleotide_len) + } + + /// Iterator of [`VisibleCodon`]s for a nucleotide viewport range. + pub fn visible_codons(&self, nuc_range: &Range) -> impl Iterator { + visible_codons(nuc_range, self.frame, self.nucleotide_len) + } + + /// The nucleotide range `[start..start+3)` for an absolute nucleotide + /// column's enclosing codon. Returns `None` for incomplete codons or + /// columns before the frame offset. + pub fn codon_span(&self, absolute_col: usize) -> Option> { + codon_span_for_absolute_column(absolute_col, self.frame, self.nucleotide_len) + } +} + +/// A window into a pre-translated byte slice, offset by `start` in +/// protein-column space. +/// +/// Used for diff-against and consensus rendering in translation mode. +#[derive(Debug, Clone, Copy)] +pub struct TranslatedByteRange<'a> { + start: usize, + bytes: &'a [u8], +} + +impl<'a> TranslatedByteRange<'a> { + /// Creates a translated byte window starting at `start` in protein space. + pub const fn new(start: usize, bytes: &'a [u8]) -> Self { + Self { start, bytes } + } + + /// Returns the byte at `protein_col`, if it lies within this window. + pub fn byte_at(self, protein_col: usize) -> Option { + let offset = protein_col.checked_sub(self.start)?; + self.bytes.get(offset).copied() + } +} + +/// Translated bytes used for diff rendering. +pub type TranslatedDiffRange<'a> = TranslatedByteRange<'a>; + +/// Number of complete three-nucleotide codons. Delegates to +/// [`ReadingFrame::complete_codons`]. +pub const fn complete_protein_len(frame: ReadingFrame, nucleotide_len: usize) -> usize { + frame.complete_codons(nucleotide_len) +} + +/// Protein column range overlapping a nucleotide viewport range. +/// +/// Returns `None` when no complete codons are visible. +pub fn visible_protein_range( + visible_nuc_range: &Range, + frame: ReadingFrame, + nucleotide_len: usize, +) -> Option> { + let last_visible_col = visible_nuc_range.end.checked_sub(1)?; + if last_visible_col < frame.offset() { + return None; + } + + let protein_len = complete_protein_len(frame, nucleotide_len); + if protein_len == 0 { + return None; + } + + let start = visible_nuc_range.start.saturating_sub(frame.offset()) / 3; + let end = ((last_visible_col - frame.offset()) / 3 + 1).min(protein_len); + + (start < end).then_some(start..end) +} + +/// Iterator of [`VisibleCodon`]s for a nucleotide viewport range. +/// +/// Only complete codons are yielded. +pub fn visible_codons( + visible_nuc_range: &Range, + frame: ReadingFrame, + nucleotide_len: usize, +) -> impl Iterator { + visible_protein_range(visible_nuc_range, frame, nucleotide_len) + .into_iter() + .flatten() + .map(move |protein_col| { + let nuc_start = nuc_start(protein_col, frame); + VisibleCodon { + protein_col, + nuc_start, + centre: nuc_start + 1, + } + }) +} + +/// The nucleotide range `[start..start+3)` for an absolute nucleotide +/// column's enclosing codon. +/// +/// Returns `None` when the column lies before the frame offset or +/// the codon extends past the alignment width (incomplete terminal codon). +pub fn codon_span_for_absolute_column( + absolute_col: usize, + frame: ReadingFrame, + nucleotide_len: usize, +) -> Option> { + let offset = frame.offset(); + if absolute_col < offset { + return None; + } + + let codon_start = offset + ((absolute_col - offset) / 3) * 3; + let codon_end = codon_start + 3; + (codon_end <= nucleotide_len).then_some(codon_start..codon_end) +} + +/// First nucleotide index of a protein column: +/// `frame.offset() + protein_col * 3`. +pub fn nuc_start(protein_col: usize, frame: ReadingFrame) -> usize { + frame.offset() + protein_col * 3 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visible_protein_range_is_correct() { + let cases = [ + ((0..1), ReadingFrame::Frame2, 8, None), + ((1..2), ReadingFrame::Frame2, 8, Some(0..1)), + ((2..7), ReadingFrame::Frame2, 8, Some(0..2)), + ((6..8), ReadingFrame::Frame2, 8, Some(1..2)), + ((7..8), ReadingFrame::Frame2, 8, None), + ]; + + for (visible_nuc_range, frame, nucleotide_len, expected) in cases { + let actual = visible_protein_range(&visible_nuc_range, frame, nucleotide_len); + assert_eq!(actual, expected, "range {visible_nuc_range:?} in {frame:?}"); + } + } + + #[test] + fn visible_codons_absolute_positions() { + let codons: Vec = visible_codons(&(2..7), ReadingFrame::Frame2, 8).collect(); + + assert_eq!( + codons, + vec![ + VisibleCodon { + protein_col: 0, + nuc_start: 1, + centre: 2, + }, + VisibleCodon { + protein_col: 1, + nuc_start: 4, + centre: 5, + }, + ] + ); + } + + #[test] + fn codon_span_maps_column_to_codon() { + let frame = ReadingFrame::Frame2; + + let cases = [ + (0, None), + (1, Some(1..4)), + (2, Some(1..4)), + (3, Some(1..4)), + (4, Some(4..7)), + (6, Some(4..7)), + (7, None), + ]; + + for (absolute_col, expected) in cases { + let actual = codon_span_for_absolute_column(absolute_col, frame, 8); + assert_eq!(actual, expected, "column {absolute_col}"); + } + } + + #[test] + fn translated_byte_range_resolves_offset() { + let range = TranslatedByteRange::new(2, b"MKF"); + assert_eq!(range.byte_at(2), Some(b'M')); + assert_eq!(range.byte_at(3), Some(b'K')); + assert_eq!(range.byte_at(4), Some(b'F')); + assert_eq!(range.byte_at(1), None); + assert_eq!(range.byte_at(5), None); + } +} diff --git a/salti/src/core/gff.rs b/salti/src/core/gff.rs new file mode 100644 index 0000000..343d94d --- /dev/null +++ b/salti/src/core/gff.rs @@ -0,0 +1,192 @@ +use std::{cmp::Reverse, fmt, fs::File, io::BufReader, ops::Range, path::Path}; + +use anyhow::{Result, format_err}; +use noodles_gff as gff; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Gff { + pub features: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Feature { + pub name: String, + pub kind: FeatureType, + pub range: Range, + pub strand: Strand, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FeatureType { + Gene, +} + +impl FeatureType { + pub fn as_str(self) -> &'static str { + match self { + Self::Gene => "gene", + } + } + + fn parse(feature_type: &[u8]) -> Option { + if feature_type.eq_ignore_ascii_case(b"gene") { + return Some(Self::Gene); + } + + None + } +} + +impl fmt::Display for FeatureType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Strand { + Forward, + Reverse, + Unknown, +} + +impl Strand { + pub fn as_str(self) -> &'static str { + match self { + Self::Forward => "Forward →", + Self::Reverse => "Reverse ←", + Self::Unknown => "Unknown strand", + } + } +} + +impl fmt::Display for Strand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From for Strand { + fn from(strand: gff::feature::record::Strand) -> Self { + match strand { + gff::feature::record::Strand::Forward => Self::Forward, + gff::feature::record::Strand::Reverse => Self::Reverse, + _ => Self::Unknown, + } + } +} + +pub fn parse_gff(path: &Path) -> Result { + let file = File::open(path).map_err(|e| format_err!("failed to open gff file: {e}"))?; + let mut reader = gff::io::Reader::new(BufReader::new(file)); + + let mut features: Vec = reader + .record_bufs() + .map(|result| { + let record = result.map_err(|e| format_err!("failed to parse gff record: {e}"))?; + let Some(kind) = FeatureType::parse(record.ty().as_ref()) else { + return Ok(None); + }; + let start = usize::from(record.start()) + .checked_sub(1) + .ok_or_else(|| format_err!("gff feature start must be one-based"))?; + let end = usize::from(record.end()); + + Ok(Some(Feature { + name: extract_name(&record), + kind, + range: start..end, + strand: record.strand().into(), + })) + }) + .filter_map(Result::transpose) + .collect::>()?; + + if features.is_empty() { + return Err(format_err!("no supported features found in gff file")); + } + + // GFFS are not always sorted + features.sort_by_key(|feature| (feature.range.start, Reverse(feature.range.end))); + + Ok(Gff { features }) +} + +fn extract_name(record: &gff::feature::RecordBuf) -> String { + const POSSIBLE_NAMES: [&[u8]; 4] = [b"Name", b"ID", b"gene_name", b"product"]; + + // try get names in order of preference, or falls back to record type so at at least something is shown + POSSIBLE_NAMES + .iter() + .filter_map(|tag| record.attributes().get(tag)) + .filter_map(|value| value.as_string()) + .find(|name| !name.is_empty()) + .map_or_else(|| record.ty().to_string(), |name| name.to_string()) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + fn write_gff(content: &str) -> tempfile::NamedTempFile { + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(content.as_bytes()).unwrap(); + file + } + + #[test] + fn parse_gff_extracts_supported_features_only() { + let gff = write_gff( + "##gff-version 3\n\ + chr1\t.\tgene\t1\t100\t.\t+\t.\tID=gene1;Name=gene1\n\ + chr1\t.\tCDS\t20\t80\t.\t+\t.\tID=cds1;Name=CDS1\n", + ); + let model = parse_gff(gff.path()).unwrap(); + + assert_eq!(model.features.len(), 1); + assert_eq!(model.features[0].name, "gene1"); + assert_eq!(model.features[0].kind, FeatureType::Gene); + assert_eq!(model.features[0].range, 0..100); + assert_eq!(model.features[0].strand, Strand::Forward); + } + + #[test] + fn parse_gff_sorts_features_by_start() { + let gff = write_gff( + "##gff-version 3\n\ + chr1\t.\tgene\t50\t150\t.\t+\t.\tID=g2;Name=gene2\n\ + chr1\t.\tgene\t1\t100\t.\t+\t.\tID=g1;Name=gene1\n", + ); + let model = parse_gff(gff.path()).unwrap(); + + assert_eq!(model.features[0].name, "gene1"); + assert_eq!(model.features[1].name, "gene2"); + } + + #[test] + fn parse_gff_falls_back_to_id_when_no_name() { + let gff = write_gff( + "##gff-version 3\n\ + chr1\t.\tgene\t1\t10\t.\t+\t.\tID=id1\n", + ); + let model = parse_gff(gff.path()).unwrap(); + + assert_eq!(model.features[0].name, "id1"); + } + + #[test] + fn parse_gff_no_supported_features_returns_error() { + let gff = write_gff( + "##gff-version 3\n\ + chr1\t.\tCDS\t1\t10\t.\t+\t.\tID=cds1\n", + ); + let result = parse_gff(gff.path()); + + assert_eq!( + result.unwrap_err().to_string(), + "no supported features found in gff file" + ); + } +} diff --git a/salti/src/core/mod.rs b/salti/src/core/mod.rs index 33ce714..3101f4a 100644 --- a/salti/src/core/mod.rs +++ b/salti/src/core/mod.rs @@ -1,3 +1,5 @@ +pub(crate) mod codon; +pub(crate) mod gff; pub mod model; pub mod parser; pub mod search; diff --git a/salti/src/core/model.rs b/salti/src/core/model.rs index c882239..c6bca47 100644 --- a/salti/src/core/model.rs +++ b/salti/src/core/model.rs @@ -1,5 +1,7 @@ use std::{fmt, ops::Range, str::FromStr}; +use crate::core::codon::{TranslationOverlay, complete_protein_len, visible_protein_range}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum StatsView { Raw, @@ -118,6 +120,7 @@ impl RowPresentationState { pub struct FilterState { pattern: Option, max_gap_fraction: Option, + min_constant_fraction: Option, } impl FilterState { @@ -129,22 +132,40 @@ impl FilterState { self.max_gap_fraction } + pub fn min_constant_fraction(&self) -> Option { + self.min_constant_fraction + } + pub fn has_column_filter(&self) -> bool { - self.max_gap_fraction.is_some() + self.max_gap_fraction.is_some() || self.min_constant_fraction.is_some() } pub fn is_active(&self) -> bool { - self.pattern.is_some() || self.max_gap_fraction.is_some() + self.pattern.is_some() + || self.max_gap_fraction.is_some() + || self.min_constant_fraction.is_some() } } +#[derive(Debug, Clone)] +struct Stash { + base: libmsa::Alignment, +} + +#[derive(Debug, Clone)] +enum TranslationMode { + Off, + Overlay, + ReloadedProtein { stash: Stash }, +} + #[derive(Debug)] pub struct AlignmentModel { base: libmsa::Alignment, view: libmsa::Alignment, rows: RowPresentationState, filter: FilterState, - translation_enabled: bool, + translation_mode: TranslationMode, translation_frame: libmsa::ReadingFrame, pub diff_mode: DiffMode, pub consensus_method: libmsa::ConsensusMethod, @@ -164,7 +185,7 @@ impl AlignmentModel { base, rows: RowPresentationState::default(), filter: FilterState::default(), - translation_enabled: false, + translation_mode: TranslationMode::Off, translation_frame: libmsa::ReadingFrame::Frame1, diff_mode: DiffMode::default(), consensus_method: libmsa::ConsensusMethod::default(), @@ -188,42 +209,52 @@ impl AlignmentModel { } pub fn translation(&self) -> Option { - self.translation_enabled.then_some(self.translation_frame) + match &self.translation_mode { + TranslationMode::Overlay => Some(self.translation_frame), + TranslationMode::Off | TranslationMode::ReloadedProtein { .. } => None, + } + } + + pub fn is_reloaded_as_protein(&self) -> bool { + matches!( + &self.translation_mode, + TranslationMode::ReloadedProtein { .. } + ) } - #[cfg(test)] pub fn translation_frame(&self) -> libmsa::ReadingFrame { self.translation_frame } pub fn pin(&mut self, abs_row: usize) -> Result<(), libmsa::AlignmentError> { self.rows.pin(abs_row, self.base_row_count())?; - self.derive_view_from_intent() + self.update_current_view() } pub fn unpin(&mut self, abs_row: usize) -> Result<(), libmsa::AlignmentError> { self.rows.unpin(abs_row, self.base_row_count())?; - self.derive_view_from_intent() + self.update_current_view() } pub fn set_reference(&mut self, abs_row: usize) -> Result<(), libmsa::AlignmentError> { self.rows.set_reference(abs_row, self.base_row_count())?; - self.derive_view_from_intent() + self.update_current_view() } pub fn clear_reference(&mut self) -> Result<(), libmsa::AlignmentError> { self.rows.clear_reference(); - self.derive_view_from_intent() + self.update_current_view() } - pub fn set_filter(&mut self, pattern: String) -> Result<(), libmsa::AlignmentError> { + pub fn set_filter(&mut self, pattern: impl Into) -> Result<(), libmsa::AlignmentError> { + let pattern = pattern.into(); let next_pattern = if pattern.is_empty() { None } else { Some(pattern) }; let previous = std::mem::replace(&mut self.filter.pattern, next_pattern); - if let Err(error) = self.derive_view_from_intent() { + if let Err(error) = self.update_current_view() { self.filter.pattern = previous; return Err(error); } @@ -236,17 +267,31 @@ impl AlignmentModel { ) -> Result<(), libmsa::AlignmentError> { let previous = self.filter.max_gap_fraction; self.filter.max_gap_fraction = max_gap_fraction; - if let Err(error) = self.derive_view_from_intent() { + if let Err(error) = self.update_current_view() { self.filter.max_gap_fraction = previous; return Err(error); } Ok(()) } + pub fn set_constant_filter( + &mut self, + min_constant_fraction: Option, + ) -> Result<(), libmsa::AlignmentError> { + let previous = self.filter.min_constant_fraction; + self.filter.min_constant_fraction = min_constant_fraction; + if let Err(error) = self.update_current_view() { + self.filter.min_constant_fraction = previous; + return Err(error); + } + Ok(()) + } + pub fn clear_filter(&mut self) -> Result<(), libmsa::AlignmentError> { self.filter.pattern = None; self.filter.max_gap_fraction = None; - self.derive_view_from_intent() + self.filter.min_constant_fraction = None; + self.update_current_view() } pub fn set_active_kind( @@ -254,10 +299,12 @@ impl AlignmentModel { kind: libmsa::AlignmentType, ) -> Result<(), libmsa::AlignmentError> { self.base.set_override_type(kind); - if kind != libmsa::AlignmentType::Dna { - self.translation_enabled = false; + if kind != libmsa::AlignmentType::Dna + && matches!(&self.translation_mode, TranslationMode::Overlay) + { + self.translation_mode = TranslationMode::Off; } - self.derive_view_from_intent() + self.update_current_view() } pub fn set_translation( @@ -265,7 +312,12 @@ impl AlignmentModel { frame: Option, ) -> Result<(), libmsa::AlignmentError> { let Some(frame) = frame else { - self.translation_enabled = false; + if !matches!( + &self.translation_mode, + TranslationMode::ReloadedProtein { .. } + ) { + self.translation_mode = TranslationMode::Off; + } return Ok(()); }; @@ -277,7 +329,7 @@ impl AlignmentModel { } self.view.translated(frame)?; - self.translation_enabled = true; + self.translation_mode = TranslationMode::Overlay; self.translation_frame = frame; Ok(()) } @@ -286,6 +338,19 @@ impl AlignmentModel { &mut self, frame: libmsa::ReadingFrame, ) -> Result<(), libmsa::AlignmentError> { + let protein = match &self.translation_mode { + TranslationMode::ReloadedProtein { stash: dna_stash } => { + Some(dna_stash.base.translated(frame)?.to_alignment()?) + } + TranslationMode::Off | TranslationMode::Overlay => None, + }; + + if let Some(protein) = protein { + self.translation_frame = frame; + self.base = protein; + return self.update_current_view(); + } + if self.base.active_type() != libmsa::AlignmentType::Dna { return Err(libmsa::AlignmentError::UnsupportedOperation { operation: "set translation frame", @@ -299,43 +364,103 @@ impl AlignmentModel { } pub fn toggle_translation_view(&mut self) -> Result<(), libmsa::AlignmentError> { - if self.translation_enabled { - self.translation_enabled = false; + if matches!(&self.translation_mode, TranslationMode::Off) { + return self.set_translation(Some(self.translation_frame)); + } + + if matches!(&self.translation_mode, TranslationMode::Overlay) { + self.translation_mode = TranslationMode::Off; return Ok(()); } - self.set_translation(Some(self.translation_frame)) + + Err(libmsa::AlignmentError::UnsupportedOperation { + operation: "set translation", + kind: self.base.active_type(), + }) } - pub fn translated_view(&self) -> Option> { - let frame = self.translation()?; - self.view.translated(frame).ok() + pub fn toggle_reload_as_protein( + &mut self, + frame: Option, + ) -> Result<(), libmsa::AlignmentError> { + if let Some(frame) = frame { + self.translation_frame = frame; + } + + if matches!( + &self.translation_mode, + TranslationMode::ReloadedProtein { .. } + ) { + let translation_mode = + std::mem::replace(&mut self.translation_mode, TranslationMode::Off); + let dna_stash = match translation_mode { + TranslationMode::ReloadedProtein { stash: dna_stash } => dna_stash, + TranslationMode::Off | TranslationMode::Overlay => unreachable!(), + }; + + self.base = dna_stash.base; + self.translation_mode = TranslationMode::Off; + return self.update_current_view(); + } + + let protein = self + .base + .translated(self.translation_frame)? + .to_alignment()?; + let dna_stash = Stash { + base: self.base.clone(), + }; + self.base = protein; + self.translation_mode = TranslationMode::ReloadedProtein { stash: dna_stash }; + self.update_current_view() } - pub fn stats_context(&self, visible_col_range: Range) -> Option { - if let Some(frame) = self.translation() { - let nucleotide_len = self.view().column_count(); - let total_columns = complete_protein_len(frame, nucleotide_len); - if total_columns == 0 { - return None; - } - let range = visible_protein_range(&visible_col_range, frame, nucleotide_len)?; - return Some(StatsContext { - view: StatsView::Translated(frame), - range, - total_columns, - }); + pub fn translated_view(&self) -> Option> { + match &self.translation_mode { + TranslationMode::Overlay => self.view.translated(self.translation_frame).ok(), + TranslationMode::Off | TranslationMode::ReloadedProtein { .. } => None, } + } - let total_columns = self.view().column_count(); - if total_columns == 0 || visible_col_range.is_empty() { - return None; + pub(crate) fn translation_overlay(&self) -> Option { + match &self.translation_mode { + TranslationMode::Overlay => Some(TranslationOverlay { + frame: self.translation_frame, + nucleotide_len: self.view().column_count(), + }), + TranslationMode::Off | TranslationMode::ReloadedProtein { .. } => None, } + } - Some(StatsContext { - view: StatsView::Raw, - range: visible_col_range, - total_columns, - }) + pub fn stats_context(&self, visible_col_range: Range) -> Option { + match &self.translation_mode { + TranslationMode::Overlay => { + let frame = self.translation_frame; + let nucleotide_len = self.view().column_count(); + let total_columns = complete_protein_len(frame, nucleotide_len); + if total_columns == 0 { + return None; + } + let range = visible_protein_range(&visible_col_range, frame, nucleotide_len)?; + Some(StatsContext { + view: StatsView::Translated(frame), + range, + total_columns, + }) + } + TranslationMode::Off | TranslationMode::ReloadedProtein { .. } => { + let total_columns = self.view().column_count(); + if total_columns == 0 || visible_col_range.is_empty() { + return None; + } + + Some(StatsContext { + view: StatsView::Raw, + range: visible_col_range, + total_columns, + }) + } + } } pub fn jump_to_sequence(&self, abs_row: usize) -> Option { @@ -356,7 +481,7 @@ impl AlignmentModel { self.base.row_count() } - fn derive_view_from_intent(&mut self) -> Result<(), libmsa::AlignmentError> { + fn update_current_view(&mut self) -> Result<(), libmsa::AlignmentError> { let mut builder = self.base.filter()?; builder = builder.without_rows(self.rows.excluded_rows()); if let Some(pattern) = self.filter.pattern() { @@ -365,6 +490,9 @@ impl AlignmentModel { if let Some(max_gap_fraction) = self.filter.max_gap_fraction() { builder = builder.with_max_gap_fraction(max_gap_fraction); } + if let Some(min_constant_fraction) = self.filter.min_constant_fraction() { + builder = builder.with_min_constant_fraction(min_constant_fraction); + } self.view = builder.apply()?; Ok(()) } @@ -380,34 +508,6 @@ fn validate_row_id(abs_row: usize, row_count: usize) -> Result<(), libmsa::Align }) } -const fn complete_protein_len(frame: libmsa::ReadingFrame, nucleotide_len: usize) -> usize { - nucleotide_len.saturating_sub(frame.offset()) / 3 -} - -fn visible_protein_range( - visible_nucleotide_range: &Range, - frame: libmsa::ReadingFrame, - nucleotide_len: usize, -) -> Option> { - let last_visible_col = visible_nucleotide_range.end.checked_sub(1)?; - if last_visible_col < frame.offset() { - return None; - } - - let protein_len = complete_protein_len(frame, nucleotide_len); - if protein_len == 0 { - return None; - } - - let start = visible_nucleotide_range - .start - .saturating_sub(frame.offset()) - / 3; - let end = ((last_visible_col - frame.offset()) / 3 + 1).min(protein_len); - - (start < end).then_some(start..end) -} - #[cfg(test)] mod tests { use super::{AlignmentModel, DiffMode, RowPresentationState, StatsContext, StatsView}; @@ -420,8 +520,8 @@ mod tests { } fn alignment_model(sequences: Vec) -> AlignmentModel { - let alignment = libmsa::Alignment::new(sequences).expect("alignment should be valid"); - AlignmentModel::new(alignment).expect("alignment model should build") + let alignment = libmsa::Alignment::new(sequences).unwrap(); + AlignmentModel::new(alignment).unwrap() } #[test] @@ -438,7 +538,7 @@ mod tests { } #[test] - fn row_presentation_state_rejects_pinning_the_reference() { + fn row_presentation_state_rejects_pinning_reference() { let mut state = RowPresentationState::default(); state.set_reference(1, 3).unwrap(); @@ -451,26 +551,18 @@ mod tests { } #[test] - fn row_presentation_state_sets_reference() { - let mut state = RowPresentationState::default(); - - state.set_reference(1, 3).unwrap(); - - assert_eq!(state.reference(), Some(1)); - } - - #[test] - fn row_presentation_state_removes_row_when_reference_set() { + fn row_presentation_state_sets_reference_and_unpins_row() { let mut state = RowPresentationState::default(); state.pin(1, 3).unwrap(); state.set_reference(1, 3).unwrap(); + assert_eq!(state.reference(), Some(1)); assert!(!state.is_pinned(1)); } #[test] - fn row_presentation_state_excluded_rows() { + fn row_presentation_state_lists_pinned_rows_before_reference() { let mut state = RowPresentationState::default(); state.pin(1, 4).unwrap(); state.pin(3, 4).unwrap(); @@ -513,8 +605,8 @@ mod tests { } #[test] - fn alignment_model_new_clones_base_into_view() { - let model = alignment_model(vec![raw("row1", b"ACGT"), raw("row2", b"TGCA")]); + fn alignment_model_new_sets_view_and_defaults() { + let model = alignment_model(vec![raw("seq1", b"CATC"), raw("seq2", b"ATAC")]); assert_eq!(model.base().row_count(), 2); assert_eq!(model.view().row_count(), 2); @@ -524,13 +616,13 @@ mod tests { #[test] fn alignment_model_new_rejects_filtered_alignment() { - let alignment = libmsa::Alignment::new(vec![raw("row1", b"ACGT"), raw("row2", b"ACGT")]) - .expect("alignment should be valid") + let alignment = libmsa::Alignment::new(vec![raw("seq1", b"CATC"), raw("seq2", b"CATC")]) + .unwrap() .filter() - .expect("filter builder should build") - .with_row_regex("row1") + .unwrap() + .with_row_regex("seq1") .apply() - .expect("filtered alignment should build"); + .unwrap(); let error = AlignmentModel::new(alignment).unwrap_err(); @@ -546,9 +638,9 @@ mod tests { #[test] fn pin_hides_row_from_view() { let mut model = alignment_model(vec![ - raw("row1", b"ACGT"), - raw("row2", b"ACGT"), - raw("row3", b"ACGT"), + raw("seq1", b"CATC"), + raw("seq2", b"CATC"), + raw("seq3", b"CATC"), ]); model.pin(1).unwrap(); @@ -561,9 +653,9 @@ mod tests { #[test] fn unpin_restores_row_to_view() { let mut model = alignment_model(vec![ - raw("row1", b"ACGT"), - raw("row2", b"ACGT"), - raw("row3", b"ACGT"), + raw("seq1", b"CATC"), + raw("seq2", b"CATC"), + raw("seq3", b"CATC"), ]); model.pin(1).unwrap(); @@ -577,9 +669,9 @@ mod tests { #[test] fn set_reference_hides_row_from_view() { let mut model = alignment_model(vec![ - raw("row1", b"ACGT"), - raw("row2", b"ACGT"), - raw("row3", b"ACGT"), + raw("seq1", b"CATC"), + raw("seq2", b"CATC"), + raw("seq3", b"CATC"), ]); model.set_reference(1).unwrap(); @@ -591,7 +683,7 @@ mod tests { #[test] fn clear_reference_restores_row_to_view() { - let mut model = alignment_model(vec![raw("row1", b"ACGT"), raw("row2", b"ACGT")]); + let mut model = alignment_model(vec![raw("seq1", b"CATC"), raw("seq2", b"CATC")]); model.set_reference(1).unwrap(); model.clear_reference().unwrap(); @@ -603,25 +695,25 @@ mod tests { #[test] fn set_filter_applies_row_pattern() { let mut model = alignment_model(vec![ - raw("alpha", b"ACGT"), - raw("beta", b"ACGT"), - raw("gamma", b"ACGT"), + raw("seq1", b"CATC"), + raw("seq2", b"CATC"), + raw("seq3", b"CATC"), ]); - model.set_filter("alpha|beta".to_string()).unwrap(); + model.set_filter("seq1|seq2".to_string()).unwrap(); - assert_eq!(model.filter().pattern(), Some("alpha|beta")); + assert_eq!(model.filter().pattern(), Some("seq1|seq2")); assert_eq!(model.view().row_count(), 2); } #[test] - fn set_filter_treats_empty_string_as_clear() { + fn set_filter_clears_pattern_on_empty_string() { let mut model = alignment_model(vec![ - raw("alpha", b"ACGT"), - raw("beta", b"ACGT"), - raw("gamma", b"ACGT"), + raw("seq1", b"CATC"), + raw("seq2", b"CATC"), + raw("seq3", b"CATC"), ]); - model.set_filter("alpha|beta".to_string()).unwrap(); + model.set_filter("seq1|seq2".to_string()).unwrap(); model.set_filter(String::new()).unwrap(); @@ -630,22 +722,22 @@ mod tests { } #[test] - fn set_filter_restores_previous_pattern_on_error() { - let mut model = alignment_model(vec![raw("alpha", b"ACGT"), raw("beta", b"ACGT")]); - model.set_filter("alpha".to_string()).unwrap(); + fn set_filter_keeps_previous_pattern_on_error() { + let mut model = alignment_model(vec![raw("seq1", b"CATC"), raw("seq2", b"CATC")]); + model.set_filter("seq1".to_string()).unwrap(); let error = model.set_filter("(".to_string()).unwrap_err(); - assert_eq!(model.filter().pattern(), Some("alpha")); + assert_eq!(model.filter().pattern(), Some("seq1")); assert!(matches!(error, libmsa::AlignmentError::InvalidRegex { .. })); } #[test] - fn set_gap_filter_applies_column_filter() { + fn set_gap_filter_hides_filtered_columns() { let mut model = alignment_model(vec![ - raw("alpha", b"A--T"), - raw("beta", b"A--T"), - raw("gamma", b"ACGT"), + raw("seq1", b"C--C"), + raw("seq2", b"C--C"), + raw("seq3", b"CATC"), ]); model.set_gap_filter(Some(0.0)).unwrap(); @@ -655,26 +747,44 @@ mod tests { } #[test] - fn clear_filter_removes_both_filters() { + fn set_constant_filter_hides_constant_columns() { + let alignment = libmsa::Alignment::new(vec![ + raw("seq1", b"CN-C"), + raw("seq2", b"C--C"), + raw("seq3", b"CTGC"), + ]) + .unwrap(); + let mut model = AlignmentModel::new(alignment).unwrap(); + + model.set_constant_filter(Some(1.0)).unwrap(); + + assert_eq!(model.filter().min_constant_fraction(), Some(1.0)); + assert_eq!(model.view().column_count(), 0); + } + + #[test] + fn clear_filter_removes_row_and_column_filters() { let mut model = alignment_model(vec![ - raw("alpha", b"A--T"), - raw("beta", b"A--T"), - raw("gamma", b"ACGT"), + raw("seq1", b"C--C"), + raw("seq2", b"C--C"), + raw("seq3", b"CATC"), ]); - model.set_filter("alpha|beta".to_string()).unwrap(); + model.set_filter("seq1|seq2".to_string()).unwrap(); model.set_gap_filter(Some(0.0)).unwrap(); + model.set_constant_filter(Some(1.0)).unwrap(); model.clear_filter().unwrap(); assert_eq!(model.filter().pattern(), None); assert_eq!(model.filter().max_gap_fraction(), None); + assert_eq!(model.filter().min_constant_fraction(), None); assert_eq!(model.view().row_count(), 3); assert_eq!(model.view().column_count(), 4); } #[test] - fn set_active_kind_disables_translation_when_leaving_dna() { - let mut model = alignment_model(vec![raw("dna", b"ATGAAATTT")]); + fn set_active_kind_clears_translation_outside_dna() { + let mut model = alignment_model(vec![raw("seq1", b"CATCATCATCAT")]); model .set_translation(Some(libmsa::ReadingFrame::Frame1)) .unwrap(); @@ -688,8 +798,8 @@ mod tests { } #[test] - fn set_translation_enables_translation_for_dna() { - let mut model = alignment_model(vec![raw("dna", b"ATGAAATTT")]); + fn set_translation_enables_translation_in_dna() { + let mut model = alignment_model(vec![raw("seq1", b"CATCATCATCAT")]); model .set_translation(Some(libmsa::ReadingFrame::Frame2)) @@ -700,8 +810,8 @@ mod tests { } #[test] - fn set_translation_rejects_non_dna_alignments() { - let mut model = alignment_model(vec![raw("aa", b"MKF")]); + fn set_translation_rejects_non_dna_alignment() { + let mut model = alignment_model(vec![raw("seq1", b"MKF")]); model .set_active_kind(libmsa::AlignmentType::Protein) .unwrap(); @@ -721,7 +831,7 @@ mod tests { #[test] fn set_translation_frame_updates_stored_frame() { - let mut model = alignment_model(vec![raw("dna", b"ATGAAATTT")]); + let mut model = alignment_model(vec![raw("seq1", b"CATCATCATCAT")]); model .set_translation_frame(libmsa::ReadingFrame::Frame3) @@ -732,8 +842,8 @@ mod tests { } #[test] - fn set_translation_frame_rejects_non_dna_alignments() { - let mut model = alignment_model(vec![raw("aa", b"MKF")]); + fn set_translation_frame_rejects_non_dna_alignment() { + let mut model = alignment_model(vec![raw("seq1", b"MKF")]); model .set_active_kind(libmsa::AlignmentType::Protein) .unwrap(); @@ -752,8 +862,117 @@ mod tests { } #[test] - fn stats_context_returns_raw_range_unchanged() { - let model = alignment_model(vec![raw("row1", b"ACGT"), raw("row2", b"ACGT")]); + fn reload_as_protein_preserves_filter_and_restores_dna() { + let mut model = alignment_model(vec![ + raw("seq1", b"CATCATCATCAT"), + raw("seq2", b"CATCATCATCAT"), + ]); + model.set_filter("seq1".to_string()).unwrap(); + model + .set_translation(Some(libmsa::ReadingFrame::Frame2)) + .unwrap(); + + model.toggle_reload_as_protein(None).unwrap(); + + assert!(model.is_reloaded_as_protein()); + assert_eq!(model.base().active_type(), libmsa::AlignmentType::Protein); + assert_eq!(model.view().column_count(), 4); + assert_eq!(model.filter().pattern(), Some("seq1")); + assert_eq!(model.translation(), None); + + model.toggle_reload_as_protein(None).unwrap(); + + assert!(!model.is_reloaded_as_protein()); + assert_eq!(model.base().active_type(), libmsa::AlignmentType::Dna); + assert_eq!(model.filter().pattern(), Some("seq1")); + assert_eq!(model.translation(), None); + } + + #[test] + fn set_translation_frame_retranslates_reloaded_protein_alignment() { + let mut model = alignment_model(vec![raw("seq1", b"ATGCCCTAA")]); + model.toggle_reload_as_protein(None).unwrap(); + + assert_eq!(model.base().column_count(), 3); + assert_eq!(model.base().sequence(0).unwrap().byte_at(0), Some(b'M')); + + model + .set_translation_frame(libmsa::ReadingFrame::Frame2) + .unwrap(); + + assert!(model.is_reloaded_as_protein()); + assert_eq!(model.translation_frame(), libmsa::ReadingFrame::Frame2); + assert_eq!(model.base().column_count(), 3); + assert_eq!(model.base().sequence(0).unwrap().byte_at(0), Some(b'C')); + } + + #[test] + fn reload_as_protein_uses_requested_frame_and_keeps_it_global() { + let mut model = alignment_model(vec![raw("seq1", b"ATGCCCTAA")]); + + model + .toggle_reload_as_protein(Some(libmsa::ReadingFrame::Frame3)) + .unwrap(); + + assert_eq!(model.translation_frame(), libmsa::ReadingFrame::Frame3); + assert_eq!(model.base().sequence(0).unwrap().byte_at(0), Some(b'A')); + + model.toggle_reload_as_protein(None).unwrap(); + + assert_eq!(model.translation_frame(), libmsa::ReadingFrame::Frame3); + } + + #[test] + fn filters_changed_in_reloaded_protein_view_persist_in_dna() { + let mut model = alignment_model(vec![ + raw("seq1", b"CAT---CATCAT"), + raw("seq2", b"CATCATCATCAT"), + raw("seq3", b"CATCATCATCAT"), + ]); + model.toggle_reload_as_protein(None).unwrap(); + + model.set_filter("seq1|seq2".to_string()).unwrap(); + model.set_gap_filter(Some(0.5)).unwrap(); + model.set_constant_filter(Some(1.0)).unwrap(); + + assert_eq!(model.filter().pattern(), Some("seq1|seq2")); + assert_eq!(model.filter().max_gap_fraction(), Some(0.5)); + assert_eq!(model.filter().min_constant_fraction(), Some(1.0)); + + model.toggle_reload_as_protein(None).unwrap(); + + assert_eq!(model.base().active_type(), libmsa::AlignmentType::Dna); + assert_eq!(model.filter().pattern(), Some("seq1|seq2")); + assert_eq!(model.filter().max_gap_fraction(), Some(0.5)); + assert_eq!(model.filter().min_constant_fraction(), Some(1.0)); + } + + #[test] + fn live_presentation_state_persists_across_reloaded_protein_toggle() { + let mut model = alignment_model(vec![ + raw("seq1", b"CATCATCATCAT"), + raw("seq2", b"CATCATCATCAT"), + raw("seq3", b"CATCATCATCAT"), + ]); + model.toggle_reload_as_protein(None).unwrap(); + + model.pin(0).unwrap(); + model.set_reference(1).unwrap(); + model.diff_mode = DiffMode::Consensus; + model.consensus_method = libmsa::ConsensusMethod::Majority; + + model.toggle_reload_as_protein(None).unwrap(); + + assert_eq!(model.base().active_type(), libmsa::AlignmentType::Dna); + assert_eq!(model.rows().pinned(), &[0]); + assert_eq!(model.rows().reference(), Some(1)); + assert_eq!(model.diff_mode, DiffMode::Consensus); + assert_eq!(model.consensus_method, libmsa::ConsensusMethod::Majority); + } + + #[test] + fn stats_context_keeps_raw_range() { + let model = alignment_model(vec![raw("seq1", b"CATC"), raw("seq2", b"CATC")]); assert_eq!( model.stats_context(1..3), @@ -767,7 +986,7 @@ mod tests { #[test] fn stats_context_maps_translated_range_to_protein_columns() { - let mut model = alignment_model(vec![raw("row1", b"ATGAAATTT")]); + let mut model = alignment_model(vec![raw("seq1", b"ATGAAATTT")]); model .set_translation(Some(libmsa::ReadingFrame::Frame2)) .unwrap(); @@ -783,12 +1002,12 @@ mod tests { } #[test] - fn stats_context_returns_none_when_no_columns_can_be_computed() { - let model = alignment_model(vec![raw("row1", b"ACGT")]); + fn stats_context_returns_none_without_visible_columns() { + let model = alignment_model(vec![raw("seq1", b"CATC")]); assert_eq!(model.stats_context(0..0), None); - let mut translated = alignment_model(vec![raw("row1", b"AT")]); + let mut translated = alignment_model(vec![raw("seq1", b"AT")]); translated .set_translation(Some(libmsa::ReadingFrame::Frame1)) .unwrap(); @@ -796,16 +1015,16 @@ mod tests { } #[test] - fn jump_to_sequence_reports_why_hidden_rows_are_not_visible() { + fn jump_to_sequence_reports_why_row_is_hidden() { let mut model = alignment_model(vec![ - raw("row1", b"ACGT"), - raw("row2", b"ACGT"), - raw("row3", b"ACGT"), - raw("row4", b"ACGT"), + raw("seq1", b"CATC"), + raw("seq2", b"CATC"), + raw("seq3", b"CATC"), + raw("seq4", b"CATC"), ]); model.pin(0).unwrap(); model.set_reference(1).unwrap(); - model.set_filter("row4".to_string()).unwrap(); + model.set_filter("seq4".to_string()).unwrap(); assert_eq!( model.jump_to_sequence(0), diff --git a/salti/src/core/parser.rs b/salti/src/core/parser.rs index 40ed72d..805b3cb 100644 --- a/salti/src/core/parser.rs +++ b/salti/src/core/parser.rs @@ -83,68 +83,84 @@ fn open_fasta_reader(input: &str) -> Result> #[cfg(test)] mod tests { - use super::*; use tempfile::NamedTempFile; + use super::*; + fn create_temp_fasta(content: &str) -> NamedTempFile { let temp_file = NamedTempFile::new().unwrap(); std::fs::write(temp_file.path(), content).unwrap(); temp_file } - #[test] - fn test_parse_valid() { - let content = ">seq1\nA-CG\n>seq2\nTGCA\n"; + fn parse_temp_fasta(content: &str, cancel: &CancellationToken) -> Result> { let temp_file = create_temp_fasta(content); let input = temp_file.path().to_str().unwrap(); - let result = parse_fasta_file(input, &CancellationToken::new()); - let sequences = result.expect("parse should succeed"); - assert_eq!(sequences.len(), 2); - assert_eq!(sequences[0].id.as_str(), "seq1"); - assert_eq!(sequences[0].sequence.as_slice(), b"A-CG"); - assert_eq!(sequences[1].id.as_str(), "seq2"); - assert_eq!(sequences[1].sequence.as_slice(), b"TGCA"); + parse_fasta_file(input, cancel) } #[test] - fn test_parse_nonexistant() { - let result = parse_fasta_file("idontexist.fasta", &CancellationToken::new()); - assert!(result.is_err()); + fn parse_fasta_file_success() { + let sequences = + parse_temp_fasta(">seq1\nA-CG\n>seq2\nTGCA\n", &CancellationToken::new()).unwrap(); + + assert_eq!( + sequences, + vec![ + RawSequence { + id: "seq1".to_string(), + sequence: b"A-CG".to_vec(), + }, + RawSequence { + id: "seq2".to_string(), + sequence: b"TGCA".to_vec(), + }, + ] + ); } #[test] - fn test_parse_empty() { - let content = ""; - let temp_file = create_temp_fasta(content); - let input = temp_file.path().to_str().unwrap(); - let result = parse_fasta_file(input, &CancellationToken::new()); - assert!(result.is_err()); + fn parse_fasta_file_errors_missing_input() { + let error = parse_fasta_file("idontexist.fasta", &CancellationToken::new()).unwrap_err(); + + assert!(error.to_string().starts_with("Failed to open input:")); } #[test] - fn test_parse_no_seqs() { - let content = ">seq1\n>seq2\n"; - let temp_file = create_temp_fasta(content); - let input = temp_file.path().to_str().unwrap(); - let result = parse_fasta_file(input, &CancellationToken::new()); - assert!(result.is_err()); + fn parse_fasta_file_errors_empty_file() { + let error = parse_temp_fasta("", &CancellationToken::new()).unwrap_err(); + + assert!(error.to_string().starts_with("Failed to open input:")); } #[test] - fn test_parse_length_mismatch() { - let content = ">seq1\nATCG\n>seq2\nTGCAAA\n"; - let temp_file = create_temp_fasta(content); - let input = temp_file.path().to_str().unwrap(); - let result = parse_fasta_file(input, &CancellationToken::new()); - assert!(result.is_err()); + fn parse_fasta_file_errors_zero_length_sequences() { + let error = parse_temp_fasta(">seq1\n>seq2\n", &CancellationToken::new()).unwrap_err(); + + assert_eq!(error.to_string(), "Sequence has zero length for id seq1"); } #[test] - fn test_parse_invalid() { - let content = "imaninvalidfasta\nfile\n"; - let temp_file = create_temp_fasta(content); - let input = temp_file.path().to_str().unwrap(); - let result = parse_fasta_file(input, &CancellationToken::new()); - assert!(result.is_err()); + fn parse_fasta_file_errors_length_mismatch() { + let error = parse_temp_fasta(">seq1\nATCG\n>seq2\nTGCAAA\n", &CancellationToken::new()) + .unwrap_err(); + + assert_eq!( + error.to_string(), + "Sequence length mismatch: expected 4, found 6 for id seq2" + ); + } + + #[test] + fn parse_fasta_file_errors_invalid_fasta() { + let error = + parse_temp_fasta("imaninvalidfasta\nfile\n", &CancellationToken::new()).unwrap_err(); + + let message = error.to_string(); + assert!( + message.starts_with("Failed to open input:") + || message.starts_with("Error reading records:") + || message == "No valid FASTA records found in input" + ); } } diff --git a/salti/src/core/stats_cache.rs b/salti/src/core/stats_cache.rs index 6cfc13c..1d592ad 100644 --- a/salti/src/core/stats_cache.rs +++ b/salti/src/core/stats_cache.rs @@ -238,12 +238,11 @@ mod tests { position: 0, consensus: Some(consensus), conservation: Some(1.0), - gap_fraction: 0.0, } } #[test] - fn chunks_for_range_handles_chunk_boundaries() { + fn chunks_for_range_boundaries() { let cache = ChunkedCache::new(CHUNK_SIZE * 3); assert_eq!(cache.chunks_for_range(&(0..0)), 0..0); @@ -258,7 +257,7 @@ mod tests { } #[test] - fn fill_chunk_writes_summaries_into_the_chunk_range() { + fn fill_chunk() { let mut cache = ChunkedCache::new(CHUNK_SIZE + 3); cache.fill_chunk(1, vec![summary(b'A'), summary(b'C'), summary(b'G')]); @@ -285,7 +284,7 @@ mod tests { } #[test] - fn raw_chunks_to_spawn_returns_only_empty_chunks() { + fn raw_chunks_to_spawn_skips_pending_and_filled() { let mut cache = ColumnStatsCache::default(); cache.init(CHUNK_SIZE * 3); cache.mark_raw_pending(1); @@ -295,7 +294,21 @@ mod tests { } #[test] - fn store_discards_generation_mismatch() { + fn translated_chunks_to_spawn_resets_cache_on_frame_change() { + let mut cache = ColumnStatsCache::default(); + cache.init(10); + let _ = cache.translated_chunks_to_spawn(&(0..2), libmsa::ReadingFrame::Frame1, 2); + cache.mark_translated_pending(0); + + let chunks = cache.translated_chunks_to_spawn(&(0..2), libmsa::ReadingFrame::Frame2, 2); + + assert_eq!(chunks, vec![0]); + assert_eq!(cache.translated_frame, Some(libmsa::ReadingFrame::Frame2)); + assert_eq!(cache.translated.chunks[0], ChunkState::Empty); + } + + #[test] + fn store_rejects_generation_mismatch() { let mut cache = ColumnStatsCache::default(); cache.init(10); @@ -311,7 +324,7 @@ mod tests { } #[test] - fn store_discards_translated_frame_mismatch() { + fn store_rejects_translated_frame_mismatch() { let mut cache = ColumnStatsCache::default(); cache.init(10); let _ = cache.translated_chunks_to_spawn(&(0..2), libmsa::ReadingFrame::Frame1, 2); @@ -332,7 +345,7 @@ mod tests { } #[test] - fn store_fills_chunk_and_marks_it_filled() { + fn store_fills_raw_chunk() { let mut cache = ColumnStatsCache::default(); cache.init(10); cache.mark_raw_pending(0); @@ -353,7 +366,25 @@ mod tests { } #[test] - fn invalidate_all_resets_both_caches_and_bumps_generation() { + fn store_error_respawns_chunk() { + let mut cache = ColumnStatsCache::default(); + cache.init(10); + cache.mark_raw_pending(0); + + let stored = cache.store(StatsJobResult { + generation: cache.generation, + chunk_idx: 0, + view: StatsView::Raw, + summaries: Err("crashbangwallop".to_string()), + }); + + assert!(!stored); + assert_eq!(cache.raw.chunks[0], ChunkState::Empty); + assert_eq!(cache.raw_chunks_to_spawn(&(0..10)), vec![0]); + } + + #[test] + fn invalidate_all_resets_raw_and_translated() { let mut cache = ColumnStatsCache::default(); cache.init(10); let previous_generation = cache.generation; @@ -368,7 +399,7 @@ mod tests { } #[test] - fn invalidate_translated_preserves_raw_cache_and_bumps_generation() { + fn invalidate_translated_keeps_raw() { let mut cache = ColumnStatsCache::default(); cache.init(10); cache.store(StatsJobResult { diff --git a/salti/src/core/viewport.rs b/salti/src/core/viewport.rs index b4b069e..127319f 100644 --- a/salti/src/core/viewport.rs +++ b/salti/src/core/viewport.rs @@ -1,7 +1,7 @@ use std::ops::Range; -// this is essentially the scroll position in each axis -// i.e the top-left of the visible area +// This is effectively the scroll position in each axis. +// It is the top-left of the visible area. #[derive(Debug, Clone, Copy, Default)] pub struct ViewportOffsets { pub rows: usize, @@ -9,8 +9,8 @@ pub struct ViewportOffsets { pub names: usize, } -// this is how much is visible on each axis. -// this is affected by terminal size/layout etc. +// This is how much is visible on each axis. +// It is affected by terminal size and layout. #[derive(Debug, Clone, Default)] struct ViewportDims { rows: usize, @@ -18,8 +18,8 @@ struct ViewportDims { name_width: usize, } -// maximum bounds of the data. -// this is the full size of the alignment and name data, independent of the terminal. +// Maximum bounds of the data. +// This is the full size of the alignment and name data, independent of the terminal. #[derive(Debug, Clone, Default)] struct ViewportMax { rows: usize, @@ -34,10 +34,17 @@ pub struct Viewport { max: ViewportMax, } +/// Viewport window expressed in visible-row and visible-column coordinates. +/// +/// Visible coordinates map to absolute coordinates via +/// [`libmsa::Alignment::absolute_row_id`] and [`libmsa::Alignment::absolute_column_id`]. #[derive(Debug, Clone)] pub struct ViewportWindow { + /// Visible row indices into the current filtered view. pub row_range: Range, + /// Visible column indices into the current filtered view. pub col_range: Range, + /// Visible name-column indices for the sequence ID pane. pub name_range: Range, } diff --git a/salti/src/input/key.rs b/salti/src/input/key.rs index 3c74ae8..a4579da 100644 --- a/salti/src/input/key.rs +++ b/salti/src/input/key.rs @@ -1,15 +1,16 @@ use crossterm::event::KeyEvent; -use crate::command::Command; -use crate::config::keybindings; -use crate::input::route::{KeyRoute, route_key}; -use crate::overlay::overlay_state::ActiveOverlay; -use crate::ui::ui_state::UiState; +use crate::{ + command::Command, + config::keybindings, + input::route::{KeyRoute, route_key}, + ui::{layers::state::ActiveLayer, ui_state::UiState}, +}; pub(crate) fn handle_key_event(ui: &mut UiState, key: KeyEvent) -> Vec { match route_key(ui) { - KeyRoute::Palette => match ui.overlay.active_overlay.as_mut() { - Some(ActiveOverlay::Palette(palette)) => palette.handle_key_event(key), + KeyRoute::Palette => match ui.layers.active.as_mut() { + Some(ActiveLayer::Palette(palette)) => palette.handle_key_event(key), _ => Vec::new(), }, KeyRoute::Global => match keybindings::lookup(key.code, key.modifiers) { @@ -24,8 +25,7 @@ mod tests { use crossterm::event::{KeyCode, KeyEvent}; use super::*; - use crate::cli::StartupState; - use crate::overlay::command_palette::CommandPaletteState; + use crate::{cli::StartupState, ui::layers::palette::CommandPaletteState}; fn ui_state() -> UiState { UiState::new(StartupState { @@ -46,7 +46,7 @@ mod tests { #[test] fn palette_keys_are_routed_to_palette_state() { let mut ui = ui_state(); - ui.overlay.open_palette(CommandPaletteState::empty()); + ui.layers.open_palette(CommandPaletteState::empty()); let commands = handle_key_event(&mut ui, KeyEvent::from(KeyCode::Esc)); diff --git a/salti/src/input/mod.rs b/salti/src/input/mod.rs index 6aefa1c..f0f5dc8 100644 --- a/salti/src/input/mod.rs +++ b/salti/src/input/mod.rs @@ -1,5 +1,6 @@ mod key; mod mouse; +pub(crate) mod movement; mod route; pub(crate) use key::handle_key_event; diff --git a/salti/src/input/mouse.rs b/salti/src/input/mouse.rs index 9dc06c6..caab2bc 100644 --- a/salti/src/input/mouse.rs +++ b/salti/src/input/mouse.rs @@ -1,13 +1,17 @@ use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -use crate::command::Command; -use crate::core::model::AlignmentModel; -use crate::input::route::{MouseRoute, route_mouse}; -use crate::overlay::minimap::MinimapState; -use crate::overlay::overlay_state::ActiveOverlay; -use crate::ui::layout::{AppLayout, FrameLayout}; -use crate::ui::selection::{codon_span_for_absolute_column, selection_point_crosshair}; -use crate::ui::ui_state::{MouseSelection, UiState}; +use crate::{ + command::Command, + core::{gff::Gff, model::AlignmentModel}, + input::route::{MouseRoute, route_mouse}, + ui::{ + layers::{minimap::MinimapState, state::ActiveLayer}, + layout::{AppLayout, FrameLayout}, + panes::gff, + selection::selection_point_crosshair, + ui_state::{MouseSelection, UiState}, + }, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct MouseAnchor { @@ -62,23 +66,25 @@ impl MouseTracker { } } +#[allow(clippy::too_many_arguments)] pub(crate) fn handle_mouse_event( tracker: &mut MouseTracker, alignment: Option<&AlignmentModel>, + gff: Option<&Gff>, ui: &mut UiState, frame_layout: &FrameLayout, app_layout: &AppLayout, mouse: MouseEvent, ) -> Vec { let mut commands = Vec::new(); - match route_mouse(ui, frame_layout, mouse) { + ui.gff_tooltip = None; + + match route_mouse(ui, frame_layout, app_layout, mouse, gff.is_some()) { MouseRoute::Palette => (), MouseRoute::Minimap => { if let Some(alignment) = alignment { let viewport_col_range = ui.viewport.window().col_range; - if let Some(ActiveOverlay::Minimap(minimap_state)) = - ui.overlay.active_overlay.as_mut() - { + if let Some(ActiveLayer::Minimap(minimap_state)) = ui.layers.active.as_mut() { handle_minimap_mouse_event( &mut commands, alignment, @@ -90,6 +96,11 @@ pub(crate) fn handle_mouse_event( } } } + MouseRoute::GffPane => { + if let (Some(gff), Some(alignment)) = (gff, alignment) { + handle_gff_mouse_event(&mut commands, gff, alignment, ui, app_layout, mouse); + } + } MouseRoute::Alignment => { if let Some(alignment) = alignment { handle_alignment_mouse_event( @@ -124,6 +135,27 @@ fn handle_minimap_mouse_event( } } +fn handle_gff_mouse_event( + commands: &mut Vec, + gff: &Gff, + alignment: &AlignmentModel, + ui: &mut UiState, + app_layout: &AppLayout, + mouse: MouseEvent, +) { + let viewport_col_range = ui.viewport.window().col_range; + let gff_pane_rows = app_layout.gff_pane_rows; + + if let Some(cmd) = + ui.gff_pane + .handle_mouse(mouse, gff_pane_rows, &viewport_col_range, alignment) + { + commands.push(cmd); + } + + ui.gff_tooltip = gff::tooltip_at(gff, alignment, gff_pane_rows, mouse.column, mouse.row); +} + fn handle_alignment_mouse_event( commands: &mut Vec, tracker: &mut MouseTracker, @@ -149,8 +181,7 @@ fn handle_alignment_mouse_event( tracker.clear_anchors(); return; }; - let store_anchor = alignment.translation().is_some() - || mouse.modifiers.contains(KeyModifiers::CONTROL); + let store_anchor = mouse.modifiers.contains(KeyModifiers::CONTROL); tracker.box_anchor = if store_anchor { Some(anchor) } else { None }; ui.selection = Some(selection_from_anchors(anchor, anchor)); @@ -194,15 +225,14 @@ fn anchor_from_crosshair( sequence_id: usize, column: usize, ) -> Option { - let Some(frame) = alignment.translation() else { + let Some(overlay) = alignment.translation_overlay() else { return Some(MouseAnchor { sequence_id, column, end_column: column, }); }; - let codon_span = - codon_span_for_absolute_column(column, frame, alignment.view().column_count())?; + let codon_span = overlay.codon_span(column)?; Some(MouseAnchor { sequence_id, column: codon_span.start, @@ -231,9 +261,14 @@ mod tests { use ratatui::layout::Rect; use super::*; - use crate::cli::StartupState; - use crate::overlay::command_palette::CommandPaletteState; - use crate::ui::layout::{AppLayout, FrameLayout}; + use crate::{ + cli::StartupState, + core::gff::{Feature, FeatureType, Strand}, + ui::{ + layers::palette::CommandPaletteState, + layout::{AlignmentHeaderLayout, AppLayout, FrameLayout}, + }, + }; fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { libmsa::RawSequence { @@ -249,23 +284,55 @@ mod tests { }) } + fn alignment_model(sequences: Vec) -> AlignmentModel { + let alignment = libmsa::Alignment::new(sequences).expect("alignment should be valid"); + AlignmentModel::new(alignment).expect("alignment model should be valid") + } + + fn mouse_event(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::empty(), + } + } + + fn left_mouse_event( + kind: MouseEventKind, + area: Rect, + column_offset: u16, + row_offset: u16, + ) -> MouseEvent { + mouse_event(kind, area.x + column_offset, area.y + row_offset) + } + + fn feature(name: &str, start: usize, end: usize) -> Feature { + Feature { + name: name.to_string(), + kind: FeatureType::Gene, + range: start..end, + strand: Strand::Forward, + } + } + #[test] fn palette_route_masks_mouse_commands() { let mut tracker = MouseTracker::default(); let mut ui = ui_state(); - ui.overlay.open_palette(CommandPaletteState::empty()); + ui.layers.open_palette(CommandPaletteState::empty()); let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); - let app_layout = AppLayout::new(frame_layout.content_area); - let mouse = MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 10, - row: 10, - modifiers: KeyModifiers::empty(), - }; + let app_layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + let mouse = mouse_event(MouseEventKind::Down(MouseButton::Left), 10, 10); let commands = handle_mouse_event( &mut tracker, None, + None, &mut ui, &frame_layout, &app_layout, @@ -279,36 +346,289 @@ mod tests { fn minimap_route_emits_jump_command() { let sequence_a = vec![b'A'; 200]; let sequence_c = vec![b'C'; 200]; - let alignment = libmsa::Alignment::new(vec![ + let model = alignment_model(vec![ raw("row1", sequence_a.as_slice()), raw("row2", sequence_c.as_slice()), - ]) - .expect("alignment should be valid"); - let model = crate::core::model::AlignmentModel::new(alignment) - .expect("alignment model should be valid"); + ]); let mut tracker = MouseTracker::default(); let mut ui = ui_state(); let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); - let app_layout = AppLayout::new(frame_layout.content_area); + let app_layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); ui.viewport.update_dimensions(78, 10, 20); ui.viewport.set_bounds(2, 200, 4); - ui.overlay.toggle_minimap(); - let mouse = MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: frame_layout.overlay_area.x + frame_layout.overlay_area.width - 2, - row: frame_layout.overlay_area.y + frame_layout.overlay_area.height - 2, - modifiers: KeyModifiers::empty(), + ui.layers.toggle_minimap(); + let mouse = mouse_event( + MouseEventKind::Down(MouseButton::Left), + frame_layout.overlay_area.x + frame_layout.overlay_area.width - 2, + frame_layout.overlay_area.y + frame_layout.overlay_area.height - 2, + ); + + let commands = handle_mouse_event( + &mut tracker, + Some(&model), + None, + &mut ui, + &frame_layout, + &app_layout, + mouse, + ); + + assert!(matches!(commands.as_slice(), [Command::JumpToPosition(_)])); + } + + #[test] + fn translated_click_selects_codon() { + let mut model = alignment_model(vec![raw("seq1", b"ATGAAATTT"), raw("seq2", b"ATGAAATTT")]); + model + .set_translation(Some(libmsa::ReadingFrame::Frame1)) + .expect("translation should succeed"); + let mut tracker = MouseTracker::default(); + let mut ui = ui_state(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + ui.viewport.update_dimensions(60, 10, 20); + ui.viewport.set_bounds(2, 9, 4); + + let mouse = left_mouse_event( + MouseEventKind::Down(MouseButton::Left), + app_layout.alignment_pane_sequence_rows, + 1, + 0, + ); + handle_mouse_event( + &mut tracker, + Some(&model), + None, + &mut ui, + &frame_layout, + &app_layout, + mouse, + ); + + assert_eq!( + ui.selection, + Some(MouseSelection { + sequence_id: 0, + column: 0, + end_sequence_id: 0, + end_column: 2, + }) + ); + } + + #[test] + fn translated_ctrl_drag_spans_codons() { + let mut model = alignment_model(vec![raw("seq1", b"ATGAAATTT"), raw("seq2", b"ATGAAATTT")]); + model + .set_translation(Some(libmsa::ReadingFrame::Frame1)) + .expect("translation should succeed"); + let mut tracker = MouseTracker::default(); + let mut ui = ui_state(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + ui.viewport.update_dimensions(60, 10, 20); + ui.viewport.set_bounds(2, 9, 4); + let area = app_layout.alignment_pane_sequence_rows; + + for mouse in [ + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: area.x + 1, + row: area.y, + modifiers: KeyModifiers::CONTROL, + }, + left_mouse_event(MouseEventKind::Drag(MouseButton::Left), area, 7, 0), + left_mouse_event(MouseEventKind::Up(MouseButton::Left), area, 7, 0), + ] { + handle_mouse_event( + &mut tracker, + Some(&model), + None, + &mut ui, + &frame_layout, + &app_layout, + mouse, + ); + } + + assert_eq!( + ui.selection, + Some(MouseSelection { + sequence_id: 0, + column: 0, + end_sequence_id: 0, + end_column: 8, + }) + ); + } + + #[test] + fn left_click_outside_msa_clears_selection() { + let model = alignment_model(vec![raw("seq1", b"ATG"), raw("seq2", b"ATG")]); + let mut tracker = MouseTracker::default(); + let mut ui = ui_state(); + ui.selection = Some(MouseSelection { + sequence_id: 0, + column: 0, + end_sequence_id: 0, + end_column: 0, + }); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + ui.viewport.update_dimensions(60, 10, 20); + ui.viewport.set_bounds(2, 3, 4); + + handle_mouse_event( + &mut tracker, + Some(&model), + None, + &mut ui, + &frame_layout, + &app_layout, + mouse_event(MouseEventKind::Down(MouseButton::Left), 0, 0), + ); + + assert_eq!(ui.selection, None); + } + + #[test] + fn middle_drag_pans_msa() { + let model = alignment_model(vec![raw("seq1", b"ATG"), raw("seq2", b"ATG")]); + let mut tracker = MouseTracker::default(); + let mut ui = ui_state(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + let area = app_layout.alignment_pane_sequence_rows; + + handle_mouse_event( + &mut tracker, + Some(&model), + None, + &mut ui, + &frame_layout, + &app_layout, + left_mouse_event(MouseEventKind::Down(MouseButton::Middle), area, 10, 5), + ); + let commands = handle_mouse_event( + &mut tracker, + Some(&model), + None, + &mut ui, + &frame_layout, + &app_layout, + left_mouse_event(MouseEventKind::Drag(MouseButton::Middle), area, 12, 7), + ); + + assert_eq!( + commands, + vec![ + Command::ScrollUp { amount: 2 }, + Command::ScrollLeft { amount: 2 } + ] + ); + } + + #[test] + fn gff_hover_sets_tooltip() { + let model = alignment_model(vec![raw("seq1", &[b'A'; 100])]); + let gff = Gff { + features: vec![feature("gene1", 0, 100)], }; + let mut tracker = MouseTracker::default(); + let mut ui = ui_state(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 4, + AlignmentHeaderLayout::without_features(), + ); + ui.viewport.update_dimensions(60, 10, 20); + ui.viewport.set_bounds(1, 100, 4); + let mouse = mouse_event( + MouseEventKind::Moved, + app_layout.gff_pane_rows.x, + app_layout.gff_pane_rows.y, + ); let commands = handle_mouse_event( &mut tracker, Some(&model), + Some(&gff), &mut ui, &frame_layout, &app_layout, mouse, ); + assert!(commands.is_empty()); + assert!( + ui.gff_tooltip + .as_deref() + .is_some_and(|it| it.starts_with("gene1 ")) + ); + } + + #[test] + fn gff_drag_emits_jump() { + let model = alignment_model(vec![raw("seq1", &[b'A'; 100])]); + let gff = Gff { + features: vec![feature("gene1", 0, 100)], + }; + let mut tracker = MouseTracker::default(); + let mut ui = ui_state(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 4, + AlignmentHeaderLayout::without_features(), + ); + ui.viewport.update_dimensions(60, 10, 20); + ui.viewport.set_bounds(1, 100, 4); + let area = app_layout.gff_pane_rows; + + handle_mouse_event( + &mut tracker, + Some(&model), + Some(&gff), + &mut ui, + &frame_layout, + &app_layout, + mouse_event(MouseEventKind::Down(MouseButton::Left), area.x + 1, area.y), + ); + let commands = handle_mouse_event( + &mut tracker, + Some(&model), + Some(&gff), + &mut ui, + &frame_layout, + &app_layout, + mouse_event( + MouseEventKind::Drag(MouseButton::Left), + area.x + area.width - 1, + area.y, + ), + ); + assert!(matches!(commands.as_slice(), [Command::JumpToPosition(_)])); } } diff --git a/salti/src/input/movement.rs b/salti/src/input/movement.rs new file mode 100644 index 0000000..0f41199 --- /dev/null +++ b/salti/src/input/movement.rs @@ -0,0 +1,59 @@ +use std::ops::Range; + +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +use crate::command::Command; + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct HorizontalDrag { + anchor: Option, +} + +impl HorizontalDrag { + pub(crate) fn is_dragging(&self) -> bool { + self.anchor.is_some() + } + + pub(crate) fn handle_mouse( + &mut self, + mouse: MouseEvent, + area: Rect, + viewport_col_range: &Range, + total_columns: usize, + position_from_mouse: impl Fn(u16, Rect, usize) -> usize, + ) -> Option { + let viewport_span = viewport_col_range.len(); + let in_area = area.contains((mouse.column, mouse.row).into()); + + let (anchor, column) = match mouse.kind { + MouseEventKind::Down(MouseButton::Left) if in_area => { + let column = position_from_mouse(mouse.column, area, total_columns); + let anchor = if viewport_col_range.contains(&column) { + column - viewport_col_range.start + } else { + viewport_span / 2 + }; + self.anchor = Some(anchor); + (anchor, column) + } + MouseEventKind::Drag(MouseButton::Left) => { + let anchor = self.anchor?; + let column = position_from_mouse(mouse.column, area, total_columns); + (anchor, column) + } + MouseEventKind::Up(MouseButton::Left) => { + let anchor = self.anchor.take()?; + if !in_area { + return None; + } + let column = position_from_mouse(mouse.column, area, total_columns); + (anchor, column) + } + _ => return None, + }; + + let visible_target = column.saturating_sub(anchor); + Some(Command::JumpToPosition(visible_target)) + } +} diff --git a/salti/src/input/route.rs b/salti/src/input/route.rs index 9538c09..861a4aa 100644 --- a/salti/src/input/route.rs +++ b/salti/src/input/route.rs @@ -1,8 +1,10 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use crate::overlay::overlay_state::ActiveOverlay; -use crate::ui::layout::FrameLayout; -use crate::ui::ui_state::UiState; +use crate::ui::{ + layers::state::ActiveLayer, + layout::{AppLayout, FrameLayout}, + ui_state::UiState, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum KeyRoute { @@ -14,12 +16,13 @@ pub(super) enum KeyRoute { pub(super) enum MouseRoute { Palette, Minimap, + GffPane, Alignment, } pub(super) fn route_key(ui: &UiState) -> KeyRoute { - match &ui.overlay.active_overlay { - Some(ActiveOverlay::Palette(_)) => KeyRoute::Palette, + match &ui.layers.active { + Some(ActiveLayer::Palette(_)) => KeyRoute::Palette, _ => KeyRoute::Global, } } @@ -27,11 +30,13 @@ pub(super) fn route_key(ui: &UiState) -> KeyRoute { pub(super) fn route_mouse( ui: &UiState, frame_layout: &FrameLayout, + app_layout: &AppLayout, mouse: MouseEvent, + has_gff: bool, ) -> MouseRoute { - match &ui.overlay.active_overlay { - Some(ActiveOverlay::Palette(_)) => MouseRoute::Palette, - Some(ActiveOverlay::Minimap(minimap_state)) => { + match &ui.layers.active { + Some(ActiveLayer::Palette(_)) => return MouseRoute::Palette, + Some(ActiveLayer::Minimap(minimap_state)) => { let left_mouse = matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) @@ -39,15 +44,209 @@ pub(super) fn route_mouse( | MouseEventKind::Up(MouseButton::Left) ); + let is_minimap_drag = minimap_state.is_dragging() + && matches!( + mouse.kind, + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ); + if (left_mouse && minimap_state.contains_mouse(mouse, frame_layout.overlay_area)) - || (matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) - && minimap_state.is_dragging()) + || is_minimap_drag { - MouseRoute::Minimap - } else { - MouseRoute::Alignment + return MouseRoute::Minimap; } } - None => MouseRoute::Alignment, + None => (), + } + + if has_gff && app_layout.gff_pane_rows.height > 0 { + let in_gff = app_layout + .gff_pane_rows + .contains((mouse.column, mouse.row).into()); + let is_left_mouse = matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::Drag(MouseButton::Left) + | MouseEventKind::Up(MouseButton::Left) + ); + let is_hover = matches!(mouse.kind, MouseEventKind::Moved); + let is_drag = ui.gff_pane.is_dragging() + && matches!( + mouse.kind, + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ); + + if (in_gff && (is_left_mouse || is_hover)) || is_drag { + return MouseRoute::GffPane; + } + } + + MouseRoute::Alignment +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use ratatui::layout::Rect; + + use super::*; + use crate::{ + cli::StartupState, + ui::{layers::palette::CommandPaletteState, layout::AlignmentHeaderLayout}, + }; + + fn ui_state() -> UiState { + UiState::new(StartupState { + file_path: None, + initial_position: 0, + }) + } + + fn mouse_event(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::empty(), + } + } + + #[test] + fn key_uses_palette_when_open() { + let mut ui = ui_state(); + ui.layers.open_palette(CommandPaletteState::empty()); + + let route = route_key(&ui); + + assert_eq!(route, KeyRoute::Palette); + } + + #[test] + fn key_uses_global_without_palette() { + let ui = ui_state(); + + let route = route_key(&ui); + + assert_eq!(route, KeyRoute::Global); + } + + #[test] + fn palette_captures_mouse() { + let mut ui = ui_state(); + ui.layers.open_palette(CommandPaletteState::empty()); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 5, + AlignmentHeaderLayout::without_features(), + ); + let mouse = mouse_event( + MouseEventKind::Moved, + app_layout.gff_pane_rows.x, + app_layout.gff_pane_rows.y, + ); + + let route = route_mouse(&ui, &frame_layout, &app_layout, mouse, true); + + assert_eq!(route, MouseRoute::Palette); + } + + #[test] + fn minimap_captures_left_mouse_inside_track() { + let mut ui = ui_state(); + ui.layers.toggle_minimap(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + let mouse = mouse_event( + MouseEventKind::Down(MouseButton::Left), + frame_layout.overlay_area.x + frame_layout.overlay_area.width - 2, + frame_layout.overlay_area.y + frame_layout.overlay_area.height - 2, + ); + + let route = route_mouse(&ui, &frame_layout, &app_layout, mouse, false); + + assert_eq!(route, MouseRoute::Minimap); + } + + #[test] + fn gff_pane_captures_hover() { + let ui = ui_state(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 5, + AlignmentHeaderLayout::without_features(), + ); + let mouse = mouse_event( + MouseEventKind::Moved, + app_layout.gff_pane_rows.x, + app_layout.gff_pane_rows.y, + ); + + let route = route_mouse(&ui, &frame_layout, &app_layout, mouse, true); + + assert_eq!(route, MouseRoute::GffPane); + } + + #[test] + fn gff_pane_ignored_without_gff() { + let ui = ui_state(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 5, + AlignmentHeaderLayout::without_features(), + ); + let mouse = mouse_event( + MouseEventKind::Moved, + app_layout.gff_pane_rows.x, + app_layout.gff_pane_rows.y, + ); + + let route = route_mouse(&ui, &frame_layout, &app_layout, mouse, false); + + assert_eq!(route, MouseRoute::Alignment); + } + + #[test] + fn mouse_defaults_to_alignment() { + let ui = ui_state(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + let mouse = mouse_event(MouseEventKind::Moved, 0, 0); + + let route = route_mouse(&ui, &frame_layout, &app_layout, mouse, false); + + assert_eq!(route, MouseRoute::Alignment); + } + + #[test] + fn minimap_falls_through_to_gff_pane_outside_track() { + let mut ui = ui_state(); + ui.layers.toggle_minimap(); + let frame_layout = FrameLayout::new(Rect::new(0, 0, 80, 24)); + let app_layout = AppLayout::new( + frame_layout.content_area, + 5, + AlignmentHeaderLayout::without_features(), + ); + let mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: app_layout.gff_pane_rows.x, + row: app_layout.gff_pane_rows.y, + modifiers: KeyModifiers::empty(), + }; + + let route = route_mouse(&ui, &frame_layout, &app_layout, mouse, true); + + assert_eq!(route, MouseRoute::GffPane); } } diff --git a/salti/src/logging.rs b/salti/src/logging.rs index c697690..d5808d0 100644 --- a/salti/src/logging.rs +++ b/salti/src/logging.rs @@ -1,12 +1,12 @@ -use std::fs::{File, OpenOptions}; -use std::io::ErrorKind; -use std::path::PathBuf; +use std::{ + fs::{File, OpenOptions}, + io::ErrorKind, + path::PathBuf, +}; use anyhow::Result; use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::EnvFilter; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; /// fallback filter used when `RUST_LOG` env is not set. const DEFAULT_LOG_LEVEL: &str = "salti=debug"; diff --git a/salti/src/main.rs b/salti/src/main.rs index 658e2e4..3322f00 100644 --- a/salti/src/main.rs +++ b/salti/src/main.rs @@ -5,19 +5,20 @@ mod config; mod core; mod input; mod logging; -mod overlay; mod ui; mod update; +use std::io::stdout; + use anyhow::Result; use clap::Parser; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; -use crossterm::execute; -use std::io::stdout; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, +}; use tracing::{error, info}; -use crate::app::App; -use crate::cli::Cli; +use crate::{app::App, cli::Cli}; struct MouseCapture { enabled: bool, diff --git a/salti/src/overlay/mod.rs b/salti/src/overlay/mod.rs deleted file mode 100644 index e132e5f..0000000 --- a/salti/src/overlay/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub(crate) mod command_palette; -pub(crate) mod minimap; -pub(crate) mod overlay_state; -pub(crate) mod render; diff --git a/salti/src/overlay/overlay_state.rs b/salti/src/overlay/overlay_state.rs deleted file mode 100644 index a3d091d..0000000 --- a/salti/src/overlay/overlay_state.rs +++ /dev/null @@ -1,30 +0,0 @@ -use super::command_palette::CommandPaletteState; -use super::minimap::MinimapState; - -#[derive(Debug)] -pub enum ActiveOverlay { - Palette(Box), - Minimap(MinimapState), -} - -#[derive(Debug, Default)] -pub struct OverlayState { - pub active_overlay: Option, -} - -impl OverlayState { - pub fn open_palette(&mut self, palette: CommandPaletteState) { - self.active_overlay = Some(ActiveOverlay::Palette(Box::new(palette))); - } - - pub fn toggle_minimap(&mut self) { - self.active_overlay = match self.active_overlay.take() { - Some(ActiveOverlay::Minimap(_)) => None, - _ => Some(ActiveOverlay::Minimap(MinimapState::default())), - }; - } - - pub fn close(&mut self) { - self.active_overlay = None; - } -} diff --git a/salti/src/ui/alignment_pane.rs b/salti/src/ui/alignment_pane.rs deleted file mode 100644 index f8b3ed9..0000000 --- a/salti/src/ui/alignment_pane.rs +++ /dev/null @@ -1,506 +0,0 @@ -use crate::{ - core::{ - model::{AlignmentModel, DiffMode}, - stats_cache::ColumnStatsCache, - viewport::{Viewport, ViewportWindow}, - }, - ui::{ - layout::{AppLayout, RULER_HEIGHT_ROWS, pinned_section_layout}, - rows::{ - RowRenderMode, TranslatedDiffRange, format_row_spans, format_translated_row_spans, - visible_bytes, visible_protein_range, - }, - ui_state::ThemeState, - }, -}; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::macros::vertical; -use ratatui::style::Styled; -use ratatui::symbols::merge::MergeStrategy; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Paragraph}; - -const SCROLLBAR_THUMB_WIDTH: usize = 3; -const SCROLLBAR_THUMB_MIN_WIDTH: usize = 1; - -fn raw_render_mode<'a>( - alignment: &AlignmentModel, - reference_bytes: Option<&'a [u8]>, - consensus_bytes: Option<&'a [u8]>, -) -> RowRenderMode<'a> { - let diff_against = match alignment.diff_mode { - DiffMode::Off => None, - DiffMode::Reference => reference_bytes, - DiffMode::Consensus => consensus_bytes, - }; - - RowRenderMode { - alignment_type: alignment.base().active_type(), - diff_against, - } -} - -fn translated_diff_range<'a>( - diff_mode: DiffMode, - protein_range_start: usize, - reference_bytes: Option<&'a [u8]>, - consensus_bytes: Option<&'a [u8]>, -) -> Option> { - match diff_mode { - DiffMode::Off => None, - DiffMode::Reference => { - reference_bytes.map(|bytes| TranslatedDiffRange::new(protein_range_start, bytes)) - } - DiffMode::Consensus => { - consensus_bytes.map(|bytes| TranslatedDiffRange::new(protein_range_start, bytes)) - } - } -} - -fn build_sequence_row_lines( - alignment: &AlignmentModel, - window: &ViewportWindow, - metrics: &ColumnStatsCache, - area: Rect, - theme: &ThemeState, -) -> Vec> { - let band_layout = pinned_section_layout(alignment.rows().pinned().len(), area.height as usize); - let mut lines = Vec::with_capacity( - band_layout.pinned_rendered + band_layout.divider_height + window.row_range.len(), - ); - - if let Some(translated) = alignment.translated_view() { - let frame = alignment - .translation() - .expect("translated view requires an active frame"); - let nucleotide_len = alignment.view().column_count(); - let protein_range = visible_protein_range(&window.col_range, frame, nucleotide_len); - let reference_bytes: Option> = protein_range.clone().and_then(|protein_range| { - alignment - .rows() - .reference() - .and_then(|abs_row| translated.project_absolute_row(abs_row)) - .map(|sequence| { - sequence - .bytes_range(protein_range) - .expect("visible protein range must fit the translated view") - .map(|(_, byte)| byte) - .collect() - }) - }); - let consensus_bytes: Option> = protein_range.clone().and_then(|protein_range| { - protein_range - .clone() - .map(|protein_col| { - metrics - .translated_summary_at(frame, protein_col) - .map(|summary| summary.consensus.unwrap_or(b' ')) - }) - .collect() - }); - let diff_against = protein_range.as_ref().and_then(|protein_range| { - translated_diff_range( - alignment.diff_mode, - protein_range.start, - reference_bytes.as_deref(), - consensus_bytes.as_deref(), - ) - }); - - for &absolute_row in alignment - .rows() - .pinned() - .iter() - .take(band_layout.pinned_rendered) - { - let Some(sequence) = translated.project_absolute_row(absolute_row) else { - continue; - }; - let spans = format_translated_row_spans( - sequence, - &window.col_range, - nucleotide_len, - frame, - &theme.theme.sequence, - diff_against, - ); - lines.push(Line::from(spans)); - } - - if band_layout.divider_height == 1 { - lines.push(Line::from( - "─" - .repeat(area.width as usize) - .set_style(theme.styles.border), - )); - } - - for relative_row in window.row_range.clone() { - let Some(absolute_row) = alignment.view().absolute_row_id(relative_row) else { - continue; - }; - let Some(sequence) = translated.sequence_by_absolute(absolute_row) else { - continue; - }; - let spans = format_translated_row_spans( - sequence, - &window.col_range, - nucleotide_len, - frame, - &theme.theme.sequence, - diff_against, - ); - lines.push(Line::from(spans)); - } - - return lines; - } - - let reference_bytes: Option> = alignment - .rows() - .reference() - .and_then(|abs_row| alignment.view().project_absolute_row(abs_row)) - .map(|sequence| visible_bytes(sequence, &window.col_range)); - let consensus_bytes: Option> = window - .col_range - .clone() - .map(|relative_col| { - metrics - .raw_summary_at(relative_col) - .map(|summary| summary.consensus.unwrap_or(b' ')) - }) - .collect(); - let render_mode = raw_render_mode( - alignment, - reference_bytes.as_deref(), - consensus_bytes.as_deref(), - ); - - for &absolute_row in alignment - .rows() - .pinned() - .iter() - .take(band_layout.pinned_rendered) - { - let Some(projected_row) = alignment.view().project_absolute_row(absolute_row) else { - continue; - }; - let bytes = visible_bytes(projected_row, &window.col_range); - let spans = format_row_spans(&bytes, &theme.theme.sequence, render_mode); - lines.push(Line::from(spans)); - } - - if band_layout.divider_height == 1 { - lines.push(Line::from( - "─" - .repeat(area.width as usize) - .set_style(theme.styles.border), - )); - } - - for relative_row in window.row_range.clone() { - let Some(sequence) = alignment.view().sequence(relative_row) else { - continue; - }; - let bytes = visible_bytes(sequence, &window.col_range); - let spans = format_row_spans(&bytes, &theme.theme.sequence, render_mode); - lines.push(Line::from(spans)); - } - - lines -} - -fn render_sequence_rows( - f: &mut Frame, - alignment: &AlignmentModel, - window: &ViewportWindow, - metrics: &ColumnStatsCache, - area: Rect, - theme: &ThemeState, -) { - let lines = build_sequence_row_lines(alignment, window, metrics, area, theme); - f.render_widget(Paragraph::new(lines).style(theme.styles.base_block), area); -} - -fn render_scrollbar( - f: &mut Frame, - alignment: &AlignmentModel, - viewport: &Viewport, - window: &ViewportWindow, - theme: &ThemeState, - area: Rect, -) { - if area.width < 2 || area.height == 0 { - return; - } - - let total_columns = alignment.view().column_count(); - let visible_columns = window.col_range.len(); - if total_columns <= visible_columns { - return; - } - - let width = area.width.saturating_sub(2) as usize; - let max_index = total_columns.saturating_sub(1); - let col_offset = viewport.window().col_range.start; - let percent = col_offset - .saturating_mul(100) - .checked_div(max_index) - .unwrap_or(0); - let track_max = width.saturating_sub(1); - let thumb_index = if track_max == 0 { - 0 - } else { - (percent * track_max) / 100 - }; - let scrollbar_area = Rect { - x: area.x + 1, - y: area.y + area.height.saturating_sub(1), - width: area.width.saturating_sub(2), - height: 1, - }; - let thumb_width = if SCROLLBAR_THUMB_WIDTH <= width { - SCROLLBAR_THUMB_WIDTH - } else { - SCROLLBAR_THUMB_MIN_WIDTH - }; - let thumb_start = thumb_index.saturating_sub(thumb_width / 2); - let thumb_end = (thumb_start + thumb_width).min(width); - let thumb_y = scrollbar_area.y; - let thumb_colour = theme.theme.accent_alt; - - for offset in thumb_start..thumb_end { - let thumb_x = scrollbar_area.x + offset as u16; - if let Some(cell) = f.buffer_mut().cell_mut((thumb_x, thumb_y)) { - let track_colour = cell.fg; - cell.set_char('▬'); - cell.set_fg(thumb_colour); - cell.set_bg(track_colour); - } - } -} - -fn add_number_to_ruler( - number_line: &mut [Span<'static>], - centre_pos: usize, - number: usize, - theme: &ThemeState, -) { - let number_string = number.to_string(); - let number_length = number_string.len(); - let ruler_width = number_line.len(); - let start_idx = centre_pos - .saturating_sub(number_length / 2) - .min(ruler_width.saturating_sub(number_length)); - - for (offset, digit) in number_string.chars().enumerate() { - if let Some(cell) = number_line.get_mut(start_idx + offset) { - *cell = digit.to_string().set_style(theme.styles.accent); - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BreakMarker { - Leading, - Trailing, -} - -fn break_positions( - absolute_columns: &[usize], - filtered_leading: bool, - filtered_trailing: bool, -) -> Vec<(usize, BreakMarker)> { - let width = absolute_columns.len(); - if width == 0 { - return Vec::new(); - } - - let mut breaks = Vec::new(); - - if filtered_leading { - breaks.push((0, BreakMarker::Leading)); - } - - for (index, pair) in absolute_columns.windows(2).enumerate() { - if pair[1] != pair[0] + 1 { - breaks.push((index, BreakMarker::Trailing)); - } - } - - if filtered_trailing { - let last = width - 1; - if !breaks.iter().any(|&(position, _)| position == last) { - breaks.push((last, BreakMarker::Trailing)); - } - } - - breaks -} - -fn dense_break_marker_position(position: usize, marker: BreakMarker, width: usize) -> usize { - match marker { - BreakMarker::Leading => position, - BreakMarker::Trailing => { - if position + 1 < width { - position + 1 - } else { - position - } - } - } -} - -fn dense_break_spans(breaks: &[(usize, BreakMarker)], width: usize) -> Vec<(usize, usize)> { - let marker_positions: Vec = breaks - .iter() - .map(|&(position, marker)| dense_break_marker_position(position, marker, width)) - .collect(); - let mut spans = Vec::new(); - let mut cluster_start = 0; - - while cluster_start < marker_positions.len() { - let mut cluster_end = cluster_start + 1; - while cluster_end < marker_positions.len() - && marker_positions[cluster_end] <= marker_positions[cluster_end - 1] + 3 - { - cluster_end += 1; - } - - if cluster_end - cluster_start >= 2 { - spans.push(( - marker_positions[cluster_start], - marker_positions[cluster_end - 1], - )); - } - - cluster_start = cluster_end; - } - - spans -} - -fn build_ruler( - absolute_columns: &[usize], - filtered_leading: bool, - filtered_trailing: bool, - theme: &ThemeState, -) -> (Line<'static>, Line<'static>) { - let width = absolute_columns.len(); - if width == 0 { - return (Line::from(""), Line::from("")); - } - - let mut number_line = vec![Span::raw(" "); width]; - let mut marker_line = vec![Span::raw(" "); width]; - - for (index, marker_span) in marker_line.iter_mut().enumerate() { - let display_pos = absolute_columns[index] + 1; - if display_pos == 1 || display_pos.is_multiple_of(5) { - let is_major_tick = display_pos.is_multiple_of(10); - *marker_span = if is_major_tick { - "|".set_style(theme.styles.accent) - } else { - ".".set_style(theme.styles.text_dim) - }; - - if is_major_tick || display_pos == 1 { - add_number_to_ruler(&mut number_line, index, display_pos, theme); - } - } - } - - let breaks = break_positions(absolute_columns, filtered_leading, filtered_trailing); - let dense_spans = dense_break_spans(&breaks, width); - - for (position, marker) in breaks { - let marker_position = dense_break_marker_position(position, marker, width); - if dense_spans - .iter() - .any(|&(start, end)| start <= marker_position && marker_position <= end) - { - continue; - } - - let symbol = match marker { - BreakMarker::Leading => "‹", - BreakMarker::Trailing => "›", - }; - marker_line[position] = symbol.set_style(theme.styles.warning); - } - - for (start, end) in dense_spans { - for marker in marker_line.iter_mut().take(end + 1).skip(start) { - *marker = "~".set_style(theme.styles.warning); - } - } - - (Line::from(number_line), Line::from(marker_line)) -} - -fn render_ruler( - f: &mut Frame, - alignment: &AlignmentModel, - window: &ViewportWindow, - area: Rect, - theme: &ThemeState, -) { - let absolute_columns: Vec = window - .col_range - .clone() - .filter_map(|relative_col| alignment.view().absolute_column_id(relative_col)) - .collect(); - let filtered_leading = window.col_range.start == 0 - && alignment - .view() - .absolute_column_id(0) - .is_some_and(|first| first > 0); - let filtered_trailing = window.col_range.end >= alignment.view().column_count() - && alignment.base().column_count() > 0 - && alignment - .view() - .absolute_column_id(alignment.view().column_count().saturating_sub(1)) - .is_some_and(|last| last < alignment.base().column_count() - 1); - let (number_line, marker_line) = build_ruler( - &absolute_columns, - filtered_leading, - filtered_trailing, - theme, - ); - f.render_widget( - Paragraph::new(vec![number_line, marker_line]).style(theme.styles.base_block), - area, - ); -} - -pub fn render_alignment_pane( - f: &mut Frame, - layout: &AppLayout, - alignment: &AlignmentModel, - viewport: &Viewport, - metrics: &ColumnStatsCache, - theme: &ThemeState, -) { - let block = Block::bordered() - .title(Line::from("Alignment".set_style(theme.styles.accent))) - .border_style(theme.styles.border) - .style(theme.styles.base_block) - .merge_borders(MergeStrategy::Exact); - let inner_area = block.inner(layout.alignment_pane); - f.render_widget(block, layout.alignment_pane); - - let [ruler_area, sequence_rows_area] = inner_area.layout(&vertical![==RULER_HEIGHT_ROWS, *=1]); - let window = viewport.window(); - - render_ruler(f, alignment, &window, ruler_area, theme); - render_sequence_rows(f, alignment, &window, metrics, sequence_rows_area, theme); - render_scrollbar( - f, - alignment, - viewport, - &window, - theme, - layout.alignment_pane, - ); -} diff --git a/salti/src/ui/consensus_pane.rs b/salti/src/ui/consensus_pane.rs deleted file mode 100644 index 848f1dc..0000000 --- a/salti/src/ui/consensus_pane.rs +++ /dev/null @@ -1,445 +0,0 @@ -use crate::{ - core::{model::AlignmentModel, stats_cache::ColumnStatsCache, viewport::ViewportWindow}, - ui::{ - layout::AppLayout, - rows::{ - RowRenderMode, TranslatedByteRange, format_row_spans, - format_translated_byte_range_spans, format_translated_row_spans, visible_bytes, - visible_protein_range, - }, - ui_state::ThemeState, - }, -}; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Styled, Stylize}; -use ratatui::symbols::merge::MergeStrategy; -use ratatui::text::Line; -use ratatui::widgets::{Block, Paragraph}; - -const CONSERVATION_SPARK_STRS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; - -fn conservation_to_spark(value: f32) -> &'static str { - let value = value.clamp(0.0, 1.0); - let max_idx = CONSERVATION_SPARK_STRS.len() - 1; - let idx = (value * max_idx as f32).round() as usize; - CONSERVATION_SPARK_STRS[idx] -} - -fn shows_conservation_line(alignment: &AlignmentModel) -> bool { - alignment.base().active_type() != libmsa::AlignmentType::Generic -} - -fn blank_line(width: usize) -> Line<'static> { - Line::raw(" ".repeat(width)) -} - -fn translated_reference_line( - alignment: &AlignmentModel, - window: &ViewportWindow, - theme: &ThemeState, -) -> Line<'static> { - let Some(translated) = alignment.translated_view() else { - return Line::from("No reference selected".fg(theme.theme.text_dim).italic()); - }; - let frame = alignment - .translation() - .expect("translated view requires an active frame"); - let nucleotide_len = alignment.view().column_count(); - - alignment.rows().reference().map_or_else( - || Line::from("No reference selected".fg(theme.theme.text_dim).italic()), - |absolute_row| { - let Some(sequence) = translated.project_absolute_row(absolute_row) else { - return Line::from("No reference selected".fg(theme.theme.text_dim).italic()); - }; - let spans = format_translated_row_spans( - sequence, - &window.col_range, - nucleotide_len, - frame, - &theme.theme.sequence, - None, - ); - Line::from(spans) - }, - ) -} - -fn translated_consensus_line( - alignment: &AlignmentModel, - window: &ViewportWindow, - metrics: &ColumnStatsCache, - theme: &ThemeState, -) -> Line<'static> { - let Some(_translated) = alignment.translated_view() else { - return Line::from("Calculating consensus...".fg(theme.theme.text_dim).italic()); - }; - let frame = alignment - .translation() - .expect("translated view requires an active frame"); - let nucleotide_len = alignment.view().column_count(); - let Some(protein_range) = visible_protein_range(&window.col_range, frame, nucleotide_len) - else { - return blank_line(window.col_range.len()); - }; - - let consensus_bytes: Option> = protein_range - .clone() - .map(|protein_col| { - metrics - .translated_summary_at(frame, protein_col) - .map(|summary| summary.consensus.unwrap_or(b' ')) - }) - .collect(); - let Some(consensus_bytes) = consensus_bytes else { - return Line::from("Calculating consensus...".fg(theme.theme.text_dim).italic()); - }; - let spans = format_translated_byte_range_spans( - TranslatedByteRange::new(protein_range.start, &consensus_bytes), - &window.col_range, - nucleotide_len, - frame, - &theme.theme.sequence, - None, - ); - Line::from(spans) -} - -fn translated_conservation_line( - alignment: &AlignmentModel, - window: &ViewportWindow, - metrics: &ColumnStatsCache, - theme: &ThemeState, -) -> Line<'static> { - let Some(frame) = alignment.translation() else { - return Line::from( - "Calculating conservation..." - .fg(theme.theme.text_dim) - .italic(), - ); - }; - let nucleotide_len = alignment.view().column_count(); - let width = window.col_range.len(); - let mut spans = vec![ratatui::text::Span::styled(" ", theme.styles.accent_alt); width]; - - let Some(protein_range) = visible_protein_range(&window.col_range, frame, nucleotide_len) - else { - return Line::from(spans); - }; - - for protein_col in protein_range { - let Some(summary) = metrics.translated_summary_at(frame, protein_col) else { - return Line::from( - "Calculating conservation..." - .fg(theme.theme.text_dim) - .italic(), - ); - }; - let spark = summary - .conservation - .filter(|value| value.is_finite()) - .map_or(" ", conservation_to_spark); - let nuc_start = frame.offset() + protein_col * 3; - - for absolute_col in nuc_start..=nuc_start + 2 { - let Some(window_offset) = absolute_col.checked_sub(window.col_range.start) else { - continue; - }; - if window_offset >= width { - continue; - } - - spans[window_offset] = ratatui::text::Span::styled(spark, theme.styles.accent_alt); - } - } - - Line::from(spans) -} - -fn consensus_alignment_lines( - alignment: &AlignmentModel, - window: &ViewportWindow, - metrics: &ColumnStatsCache, - theme: &ThemeState, -) -> Vec> { - if alignment.translation().is_some() { - return vec![ - translated_reference_line(alignment, window, theme), - translated_consensus_line(alignment, window, metrics, theme), - translated_conservation_line(alignment, window, metrics, theme), - ]; - } - - let no_diff_mode = RowRenderMode { - alignment_type: alignment.base().active_type(), - diff_against: None, - }; - - let reference_line = alignment.rows().reference().map_or_else( - || Line::from("No reference selected".fg(theme.theme.text_dim).italic()), - |absolute_row| { - let Some(projected_row) = alignment.view().project_absolute_row(absolute_row) else { - return Line::from("No reference selected".fg(theme.theme.text_dim).italic()); - }; - let bytes = visible_bytes(projected_row, &window.col_range); - let spans = format_row_spans(&bytes, &theme.theme.sequence, no_diff_mode); - Line::from(spans) - }, - ); - - let consensus_bytes: Option> = window - .col_range - .clone() - .map(|rel_col| { - metrics - .raw_summary_at(rel_col) - .map(|summary| summary.consensus.unwrap_or(b' ')) - }) - .collect(); - - let consensus_line = consensus_bytes.map_or_else( - || Line::from("Calculating consensus...".fg(theme.theme.text_dim).italic()), - |bytes| { - let spans = format_row_spans(&bytes, &theme.theme.sequence, no_diff_mode); - Line::from(spans) - }, - ); - - if shows_conservation_line(alignment) { - let conservation_line = build_conservation_line(metrics, window, theme); - vec![reference_line, consensus_line, conservation_line] - } else { - vec![reference_line, consensus_line] - } -} - -fn render_consensus_alignment_pane( - f: &mut Frame, - area: Rect, - alignment: &AlignmentModel, - window: &ViewportWindow, - metrics: &ColumnStatsCache, - theme: &ThemeState, -) { - let block = Block::bordered() - .border_style(theme.styles.border) - .style(theme.styles.base_block) - .merge_borders(MergeStrategy::Exact); - let inner_area = block.inner(area); - f.render_widget(block, area); - - let lines = consensus_alignment_lines(alignment, window, metrics, theme); - f.render_widget( - Paragraph::new(lines).style(theme.styles.base_block), - inner_area, - ); -} - -fn build_conservation_line( - metrics: &ColumnStatsCache, - window: &ViewportWindow, - theme: &ThemeState, -) -> Line<'static> { - let mut sparkline = String::with_capacity(window.col_range.len()); - - for relative_col in window.col_range.clone() { - let Some(summary) = metrics.raw_summary_at(relative_col) else { - return Line::from( - "Calculating conservation..." - .fg(theme.theme.text_dim) - .italic(), - ); - }; - let spark = summary - .conservation - .filter(|value| value.is_finite()) - .map_or(" ", conservation_to_spark); - sparkline.push_str(spark); - } - - Line::from(sparkline).set_style(theme.styles.accent_alt) -} - -fn render_consensus_sequence_id_pane( - f: &mut Frame, - area: Rect, - alignment: &AlignmentModel, - theme: &ThemeState, -) { - let block = Block::bordered() - .border_style(theme.styles.border) - .style(theme.styles.base_block) - .merge_borders(MergeStrategy::Exact); - let inner_area = block.inner(area); - f.render_widget(block, area); - - let lines = if shows_conservation_line(alignment) { - vec![ - Line::from("Reference Sequence:".set_style(theme.styles.accent)), - Line::from("Consensus Sequence:".set_style(theme.styles.accent)), - Line::from("Conservation:".set_style(theme.styles.accent)), - ] - } else { - vec![ - Line::from("Reference Sequence:".set_style(theme.styles.accent)), - Line::from("Consensus Sequence:".set_style(theme.styles.accent)), - ] - }; - - f.render_widget( - Paragraph::new(lines).style(theme.styles.base_block), - inner_area, - ); -} - -pub fn render_consensus_pane( - f: &mut Frame, - layout: &AppLayout, - alignment: &AlignmentModel, - window: &ViewportWindow, - metrics: &ColumnStatsCache, - theme: &ThemeState, -) { - render_consensus_sequence_id_pane(f, layout.consensus_sequence_id_pane, alignment, theme); - render_consensus_alignment_pane( - f, - layout.consensus_alignment_pane, - alignment, - window, - metrics, - theme, - ); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::model::StatsView; - use crate::core::stats_cache::StatsJobResult; - - fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { - libmsa::RawSequence { - id: id.to_string(), - sequence: sequence.to_vec(), - } - } - - fn line_text(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() - } - - fn metrics_with( - view: StatsView, - consensus: &[u8], - conservation: Option, - ) -> ColumnStatsCache { - let mut cache = ColumnStatsCache::default(); - match view { - StatsView::Raw => cache.init(consensus.len()), - StatsView::Translated(frame) => { - cache.init(consensus.len() * 3); - let _ = - cache.translated_chunks_to_spawn(&(0..consensus.len()), frame, consensus.len()); - } - } - - let summaries = consensus - .iter() - .enumerate() - .map(|(position, &byte)| libmsa::ColumnSummary { - position, - consensus: Some(byte), - conservation, - gap_fraction: 0.0, - }) - .collect(); - let view = view; - let generation = cache.generation; - let chunk_idx = 0; - let stored = cache.store(StatsJobResult { - generation, - chunk_idx, - view, - summaries: Ok(summaries), - }); - assert!(stored); - cache - } - - #[test] - fn translated_consensus_lines_use_codon_spread_rendering() { - let alignment = - libmsa::Alignment::new(vec![raw("ref", b"ATGAAATTT"), raw("row", b"ATGAAATTT")]) - .expect("alignment should be valid"); - let mut alignment = - AlignmentModel::new(alignment).expect("alignment model should be created"); - alignment.set_reference(0).expect("reference should be set"); - alignment - .set_translation(Some(libmsa::ReadingFrame::Frame1)) - .expect("translation should succeed"); - - let window = ViewportWindow { - row_range: 0..alignment.view().row_count(), - col_range: 0..alignment.view().column_count(), - name_range: 0..0, - }; - let lines = consensus_alignment_lines( - &alignment, - &window, - &metrics_with( - StatsView::Translated(libmsa::ReadingFrame::Frame1), - b"MKF", - Some(1.0), - ), - &ThemeState::default(), - ); - - assert_eq!(lines.len(), 3); - assert_eq!(line_text(&lines[0]), " M K F "); - assert_eq!(line_text(&lines[1]), " M K F "); - assert_eq!(line_text(&lines[2]), "█████████"); - } - - #[test] - fn translated_mode_keeps_conservation_label() { - let alignment = - libmsa::Alignment::new(vec![raw("ref", b"ATGAAATTT"), raw("row", b"ATGAAATTT")]) - .expect("alignment should be valid"); - let mut alignment = - AlignmentModel::new(alignment).expect("alignment model should be created"); - alignment - .set_translation(Some(libmsa::ReadingFrame::Frame1)) - .expect("translation should succeed"); - - assert!(shows_conservation_line(&alignment)); - } - - #[test] - fn raw_consensus_lines_keep_conservation_row() { - let alignment = libmsa::Alignment::new(vec![raw("ref", b"ACGT"), raw("row", b"ACGT")]) - .expect("alignment should be valid"); - let mut alignment = - AlignmentModel::new(alignment).expect("alignment model should be created"); - alignment.set_reference(0).expect("reference should be set"); - - let window = ViewportWindow { - row_range: 0..alignment.view().row_count(), - col_range: 0..alignment.view().column_count(), - name_range: 0..0, - }; - let lines = consensus_alignment_lines( - &alignment, - &window, - &metrics_with(StatsView::Raw, b"ACGT", Some(1.0)), - &ThemeState::default(), - ); - - assert_eq!(lines.len(), 3); - assert_eq!(line_text(&lines[0]), "ACGT"); - assert_eq!(line_text(&lines[1]), "ACGT"); - } -} diff --git a/salti/src/ui/features.rs b/salti/src/ui/features.rs new file mode 100644 index 0000000..5fe4cd9 --- /dev/null +++ b/salti/src/ui/features.rs @@ -0,0 +1,224 @@ +use std::{num::NonZeroUsize, ops::Range}; + +use ratatui::style::Style; + +use crate::{ + core::{ + gff::{Feature, Gff}, + model::AlignmentModel, + }, + ui::ui_state::ThemeState, +}; + +const NUCLEOTIDE_POSITIONS_PER_COL: NonZeroUsize = NonZeroUsize::new(1).unwrap(); +const PROTEIN_POSITIONS_PER_COL: NonZeroUsize = NonZeroUsize::new(3).unwrap(); + +#[derive(Debug, Clone)] +pub(crate) struct DisplayFeature<'a> { + pub(crate) feature: &'a Feature, + pub(crate) relative_col_range: Range, + pub(crate) colour_idx: usize, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct FeatureStyle { + pub(crate) background: Style, + pub(crate) text: Style, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct FeatureMap { + absolute_total_columns: usize, + offset: usize, + positions_to_col: NonZeroUsize, +} + +impl FeatureMap { + pub(crate) fn for_alignment(alignment: &AlignmentModel) -> Self { + let absolute_total_columns = alignment.base().column_count(); + + if alignment.is_reloaded_as_protein() { + Self::protein( + absolute_total_columns, + alignment.translation_frame().offset(), + ) + } else { + Self::nucleotide(absolute_total_columns) + } + } + + pub(crate) fn protein(absolute_total_columns: usize, frame_offset: usize) -> Self { + Self { + absolute_total_columns, + offset: frame_offset, + positions_to_col: PROTEIN_POSITIONS_PER_COL, + } + } + + fn nucleotide(absolute_total_columns: usize) -> Self { + Self { + absolute_total_columns, + offset: 0, + positions_to_col: NUCLEOTIDE_POSITIONS_PER_COL, + } + } + + pub(crate) fn map_feature( + &self, + view: &libmsa::Alignment, + feature: &Feature, + ) -> Option> { + let absolute_range = self.map_feature_absolute_range(feature)?; + view.relative_column_range_intersecting(absolute_range) + } + + fn map_feature_absolute_range(&self, feature: &Feature) -> Option> { + let positions_to_col = self.positions_to_col.get(); + let start = feature.range.start.saturating_sub(self.offset) / positions_to_col; + let end = feature + .range + .end + .saturating_sub(self.offset) + .div_ceil(positions_to_col); + let clipped_range = + start.min(self.absolute_total_columns)..end.min(self.absolute_total_columns); + (!clipped_range.is_empty()).then_some(clipped_range) + } +} + +pub(crate) fn display_features<'a>( + gff: &'a Gff, + alignment: &AlignmentModel, +) -> Vec> { + let mapping = FeatureMap::for_alignment(alignment); + gff.features + .iter() + .filter_map(|feature| { + mapping + .map_feature(alignment.view(), feature) + .map(|range| (feature, range)) + }) + .enumerate() + .map( + |(colour_idx, (feature, relative_col_range))| DisplayFeature { + feature, + relative_col_range, + colour_idx, + }, + ) + .collect() +} + +pub(crate) fn feature_style(theme: &ThemeState, colour_idx: usize) -> FeatureStyle { + let dna = theme.theme.sequence.dna; + let colour = match colour_idx % 4 { + 0 => dna.a, + 1 => dna.t, + 2 => dna.c, + _ => dna.g, + }; + let background = theme.styles.base_block.bg(colour); + let text = background.fg(theme.theme.sequence.foreground); + FeatureStyle { background, text } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::gff::Strand; + + fn feature(start: usize, end: usize) -> Feature { + Feature { + name: "gene".to_owned(), + kind: crate::core::gff::FeatureType::Gene, + range: start..end + 1, + strand: Strand::Forward, + } + } + + fn raw(sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: "seq".to_owned(), + sequence: sequence.to_vec(), + } + } + + fn model_with_sequence(sequence: &[u8]) -> AlignmentModel { + let alignment = libmsa::Alignment::new(vec![raw(sequence)]).unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + fn model_with_len(len: usize) -> AlignmentModel { + let alignment = libmsa::Alignment::new(vec![libmsa::RawSequence { + id: "seq".to_owned(), + sequence: vec![b'A'; len], + }]) + .unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + #[test] + fn filtered_nt_collapses_hidden_cols() { + let mut model = model_with_sequence(b"-A-CC--G"); + model.set_gap_filter(Some(0.0)).unwrap(); + let mapping = FeatureMap::for_alignment(&model); + + assert!(model.view().absolute_column_ids().eq([1, 3, 4, 7])); + assert_eq!( + mapping.map_feature(model.view(), &feature(2, 6)), + Some(1..3) + ); + } + + #[test] + fn filtered_nt_hides_feature() { + let mut model = model_with_sequence(b"-A-CC--G"); + model.set_gap_filter(Some(0.0)).unwrap(); + let mapping = FeatureMap::for_alignment(&model); + + assert_eq!(mapping.map_feature(model.view(), &feature(5, 6)), None); + } + + #[test] + fn filtered_protein_projects_cols() { + let mut model = model_with_sequence(b"M-M-M"); + model.set_gap_filter(Some(0.0)).unwrap(); + let mapping = FeatureMap::protein(5, 0); + + assert!(model.view().absolute_column_ids().eq([0, 2, 4])); + assert_eq!( + mapping.map_feature(model.view(), &feature(3, 8)), + Some(1..2) + ); + } + + #[test] + fn protein_clips_before_frame() { + let model = model_with_len(5); + let mapping = FeatureMap::protein(5, 2); + + assert_eq!( + mapping.map_feature(model.view(), &feature(0, 4)), + Some(0..1) + ); + } + + #[test] + fn mapping_clips_past_alignment_end() { + let model = model_with_len(8); + let mapping = FeatureMap::for_alignment(&model); + + assert_eq!( + mapping.map_feature(model.view(), &feature(6, 10)), + Some(6..8) + ); + } + + #[test] + fn mapping_hides_after_alignment_end() { + let model = model_with_len(8); + let mapping = FeatureMap::for_alignment(&model); + + assert_eq!(mapping.map_feature(model.view(), &feature(10, 12)), None); + } +} diff --git a/salti/src/ui/frame.rs b/salti/src/ui/frame.rs deleted file mode 100644 index 71e72d6..0000000 --- a/salti/src/ui/frame.rs +++ /dev/null @@ -1,265 +0,0 @@ -use crate::{ - core::model::AlignmentModel, - ui::{ - selection::selection_row_bounds, - ui_state::{LoadingState, UiState}, - utils::truncate_label, - }, -}; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::Styled; -use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; - -/// maximum displayed character count for a selected sequence name in the status bar before truncation -const STATUS_BAR_SELECTED_NAME_MAX_CHARS: usize = 25; - -fn format_gap_percent(max_gap_fraction: f32) -> String { - let mut text = format!("{:.2}", max_gap_fraction * 100.0); - while text.ends_with('0') { - text.pop(); - } - if text.ends_with('.') { - text.pop(); - } - text -} - -fn build_bottom_status_bar(alignment: Option<&AlignmentModel>, ui: &UiState) -> Vec> { - let theme = &ui.theme.styles; - let mut parts = Vec::new(); - - if let Some(alignment) = alignment.filter(|alignment| alignment.filter().is_active()) { - let visible_rows = alignment.view().row_count(); - let mut filter_text = String::from("Filters:"); - let mut counts = format!(" ({visible_rows} rows)"); - if let Some(pattern) = alignment.filter().pattern() { - filter_text.push_str(&format!(" [rows: {pattern}]")); - } - if let Some(max_gap_fraction) = alignment.filter().max_gap_fraction() { - filter_text.push_str(&format!( - " [gaps: <= {}%]", - format_gap_percent(max_gap_fraction) - )); - let visible_cols = alignment.view().column_count(); - counts.push_str(&format!(" ({visible_cols} cols)")); - } - parts.push(format!("{filter_text}{counts}").set_style(theme.warning)); - } - - // optional selection info building - if let Some(selection) = ui.selection { - let (row_min, row_max) = selection_row_bounds(selection); - let selected_sequence_count = row_max - row_min + 1; - let col_start = selection.column.min(selection.end_column) + 1; - let col_end = selection.column.max(selection.end_column) + 1; - - if !parts.is_empty() { - parts.push(Span::raw(" | ")); - } - - if selected_sequence_count == 1 && col_start == col_end { - let sequence_name = if let Some(alignment) = alignment { - if let Some(sequence) = alignment.base().project_absolute_row(selection.sequence_id) - { - truncate_label(sequence.id(), STATUS_BAR_SELECTED_NAME_MAX_CHARS) - } else { - "Unknown".to_string() - } - } else { - "Unknown".to_string() - }; - parts.push(format!("Selected: {sequence_name} @ {col_start}").set_style(theme.text)); - } else { - parts.push( - format!("{selected_sequence_count} sequence(s) selected @ {col_start}-{col_end}") - .set_style(theme.text), - ); - } - } - - parts -} - -fn build_top_status_bar(alignment: Option<&AlignmentModel>, ui: &UiState) -> Vec> { - let theme = &ui.theme.styles; - let file_name = ui - .meta - .input_path - .as_deref() - .map(|input| { - // for local paths, show just the file name for URLs makes more sense to show the full input. - std::path::Path::new(input) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(input) - }) - .unwrap_or("Unknown"); - - let loading_text = ui.meta.loading_state.to_string(); - let loading_style = match &ui.meta.loading_state { - LoadingState::Idle | LoadingState::Loading => theme.text_dim, - LoadingState::Loaded => theme.success, - LoadingState::Failed(_) => theme.error, - }; - let loading_status = loading_text.set_style(loading_style); - - let alignment_count = alignment - .map(|alignment| alignment.view().row_count()) - .unwrap_or(0); - let alignment_length = alignment - .map(|alignment| alignment.base().column_count()) - .unwrap_or(0); - let position_range = alignment.map_or_else( - || "Positions: 0-0".to_string(), - |alignment| { - let window = ui.viewport.window(); - match ( - alignment.view().absolute_column_id(window.col_range.start), - window - .col_range - .end - .checked_sub(1) - .and_then(|end| alignment.view().absolute_column_id(end)), - ) { - (Some(start), Some(end)) => format!("Positions: {}-{}", start + 1, end + 1), - _ => "Positions: 0-0".to_string(), - } - }, - ); - - vec![ - format!("File: {file_name}").set_style(theme.text_dim), - Span::raw(" | "), - loading_status, - Span::raw(" | "), - format!("{alignment_count} alignments").set_style(theme.text), - Span::raw(" | "), - format!("Length: {alignment_length}").set_style(theme.text), - Span::raw(" | "), - position_range.set_style(theme.text), - ] -} - -pub fn render_frame( - f: &mut Frame, - top_status_area: Rect, - bottom_status_area: Rect, - alignment: Option<&AlignmentModel>, - ui: &UiState, -) { - let theme = &ui.theme.styles; - let top_status_bar = build_top_status_bar(alignment, ui); - let bottom_status_bar = build_bottom_status_bar(alignment, ui); - - if top_status_area.height > 0 { - let top_line = Line::from(top_status_bar).right_aligned(); - f.render_widget( - Paragraph::new(top_line).style(theme.panel_block), - top_status_area, - ); - } - - if bottom_status_area.height > 0 { - let contextual_line = Line::from(bottom_status_bar).right_aligned(); - f.render_widget( - Paragraph::new(contextual_line).style(theme.panel_block), - bottom_status_area, - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::StartupState; - use crate::core::model::AlignmentModel; - - fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { - libmsa::RawSequence { - id: id.to_string(), - sequence: sequence.to_vec(), - } - } - - fn status_text(spans: &[Span<'_>]) -> String { - spans.iter().map(|span| span.content.as_ref()).collect() - } - - fn top_status_text(alignment: Option<&AlignmentModel>, ui: &UiState) -> String { - status_text(&build_top_status_bar(alignment, ui)) - } - - fn ui_state() -> UiState { - let mut ui = UiState::new(StartupState::default()); - ui.meta.loading_state = LoadingState::Loaded; - ui - } - - #[test] - fn bottom_status_bar_formats_row_filter_summary() { - let alignment = libmsa::Alignment::new(vec![ - raw("alpha", b"ACGT"), - raw("beta", b"ACGT"), - raw("gamma", b"ACGT"), - ]) - .expect("alignment should be valid"); - let mut alignment = AlignmentModel::new(alignment).expect("alignment model should build"); - alignment - .set_filter("alpha|beta".to_string()) - .expect("row filter should apply"); - let ui = ui_state(); - - assert_eq!( - status_text(&build_bottom_status_bar(Some(&alignment), &ui)), - "Filters: [rows: alpha|beta] (2 rows)" - ); - } - - #[test] - fn bottom_status_bar_formats_row_and_gap_filter_summary() { - let alignment = libmsa::Alignment::new(vec![ - raw("alpha", b"A--T"), - raw("beta", b"A--T"), - raw("gamma", b"ACGT"), - ]) - .expect("alignment should be valid"); - let mut alignment = AlignmentModel::new(alignment).expect("alignment model should build"); - alignment - .set_filter("alpha|beta".to_string()) - .expect("row filter should apply"); - alignment - .set_gap_filter(Some(0.0)) - .expect("gap filter should apply"); - let ui = ui_state(); - - assert_eq!( - status_text(&build_bottom_status_bar(Some(&alignment), &ui)), - "Filters: [rows: alpha|beta] [gaps: <= 0%] (2 rows) (2 cols)" - ); - } - - #[test] - fn top_status_bar_shows_alignment_length() { - let alignment = libmsa::Alignment::new(vec![ - raw("alpha", b"ACGT"), - raw("beta", b"ACGT"), - raw("gamma", b"ACGT"), - ]) - .expect("alignment should be valid"); - let alignment = AlignmentModel::new(alignment).expect("alignment model should build"); - let mut ui = ui_state(); - ui.viewport.update_dimensions(4, 3, 0); - ui.viewport.set_bounds( - alignment.view().row_count(), - alignment.view().column_count(), - alignment.base().max_id_len(), - ); - - assert_eq!( - top_status_text(Some(&alignment), &ui), - "File: Unknown | Status: Loaded | 3 alignments | Length: 4 | Positions: 1-4" - ); - } -} diff --git a/salti/src/overlay/minimap.rs b/salti/src/ui/layers/minimap.rs similarity index 59% rename from salti/src/overlay/minimap.rs rename to salti/src/ui/layers/minimap.rs index bbc98c2..e234bdb 100644 --- a/salti/src/overlay/minimap.rs +++ b/salti/src/ui/layers/minimap.rs @@ -1,16 +1,18 @@ use std::ops::Range; -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Clear, Paragraph, Widget}; - -use crate::command::Command; -use crate::config::theme::Theme; -use crate::core::model::AlignmentModel; -use crate::ui::ui_state::UiState; +use crossterm::event::MouseEvent; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Color, + text::{Line, Span}, + widgets::{Block, Clear, Paragraph, Widget}, +}; + +use crate::{ + command::Command, config::theme::Theme, core::model::AlignmentModel, + input::movement::HorizontalDrag, ui::ui_state::UiState, +}; /// maximum height of the minimap in rows const MINIMAP_HEIGHT_ROWS: u16 = 7; @@ -27,14 +29,64 @@ pub struct MinimapLayout { pub track_area: Rect, } +pub struct Minimap<'a> { + input_area: Rect, + alignment: &'a AlignmentModel, + ui: &'a UiState, +} + +impl<'a> Minimap<'a> { + pub fn new(input_area: Rect, alignment: &'a AlignmentModel, ui: &'a UiState) -> Self { + Self { + input_area, + alignment, + ui, + } + } +} + +impl Widget for Minimap<'_> { + fn render(self, area: Rect, buffer: &mut Buffer) { + let minimap_layout = layout(area); + let theme = &self.ui.theme.theme; + let styles = &self.ui.theme.styles; + let total_columns = self.alignment.view().column_count(); + + Clear.render(minimap_layout.area, buffer); + Block::bordered() + .border_style(styles.border) + .style(styles.panel_block) + .render(minimap_layout.area, buffer); + render_minimap_track( + buffer, + minimap_layout.track_area, + self.alignment, + theme, + total_columns, + ); + + if let Some(viewport_box) = highlight_box( + minimap_layout.track_area, + self.ui.viewport.window().col_range, + total_columns, + ) { + shade_highlight_box(buffer, viewport_box, theme); + } + + Paragraph::new(Line::from(Span::styled("Drag to pan", styles.text_dim))) + .style(styles.base_block) + .render(self.input_area, buffer); + } +} + #[derive(Debug, Clone, Copy, Default)] pub struct MinimapState { - anchor_columns: Option, + pan_drag: HorizontalDrag, } impl MinimapState { pub fn is_dragging(&self) -> bool { - self.anchor_columns.is_some() + self.pan_drag.is_dragging() } pub fn contains_mouse(&self, mouse: MouseEvent, overlay_area: Rect) -> bool { @@ -43,21 +95,10 @@ impl MinimapState { } fn position_from_mouse(mouse_x: u16, track_area: Rect, total_columns: usize) -> usize { - let offset = usize::from(mouse_x - track_area.x); + let offset = usize::from(mouse_x.saturating_sub(track_area.x)); let width = usize::from(track_area.width); - let column = offset * total_columns / width; - column.min(total_columns - 1) - } - - fn pan_action( - mouse_x: u16, - track_area: Rect, - total_columns: usize, - drag_anchor: usize, - ) -> Command { - let column = Self::position_from_mouse(mouse_x, track_area, total_columns); - let visible_target = column.saturating_sub(drag_anchor); - Command::JumpToPosition(visible_target) + let column = offset.saturating_mul(total_columns) / width; + column.min(total_columns.saturating_sub(1)) } pub fn handle_mouse( @@ -67,57 +108,14 @@ impl MinimapState { viewport_column_range: &Range, total_columns: usize, ) -> Option { - if total_columns == 0 { - return None; - } - - let viewport_cols = viewport_column_range - .end - .saturating_sub(viewport_column_range.start); let track_area = layout(overlay_area).track_area; - let in_track = self.contains_mouse(mouse, overlay_area); - - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) if in_track => { - let column = Self::position_from_mouse(mouse.column, track_area, total_columns); - let drag_anchor = if viewport_column_range.contains(&column) { - column - viewport_column_range.start - } else { - viewport_cols / 2 - }; - - self.anchor_columns = Some(drag_anchor); - Some(Self::pan_action( - mouse.column, - track_area, - total_columns, - drag_anchor, - )) - } - MouseEventKind::Drag(MouseButton::Left) if in_track => { - let drag_anchor = self.anchor_columns?; - Some(Self::pan_action( - mouse.column, - track_area, - total_columns, - drag_anchor, - )) - } - MouseEventKind::Up(MouseButton::Left) => { - let drag_anchor = self.anchor_columns.take()?; - if in_track { - Some(Self::pan_action( - mouse.column, - track_area, - total_columns, - drag_anchor, - )) - } else { - None - } - } - _ => None, - } + self.pan_drag.handle_mouse( + mouse, + track_area, + viewport_column_range, + total_columns, + Self::position_from_mouse, + ) } } @@ -172,8 +170,7 @@ fn calculate_block_colour( .unwrap_or(theme.panel_bg_dim) } -fn shade_highlight_box(f: &mut Frame, viewport_box: Rect, theme: &Theme) { - let buffer = f.buffer_mut(); +fn shade_highlight_box(buffer: &mut Buffer, viewport_box: Rect, theme: &Theme) { for position in viewport_box.positions() { if let Some(cell) = buffer.cell_mut(position) { cell.set_char('▒'); @@ -203,14 +200,13 @@ pub fn highlight_box(track_area: Rect, window: Range, total_columns: usiz } fn render_minimap_track( - f: &mut Frame, + buffer: &mut Buffer, area: Rect, alignment: &AlignmentModel, theme: &Theme, total_columns: usize, ) { let total_width = usize::from(area.width); - let buffer = f.buffer_mut(); // render empty block if alignment is empty if total_columns == 0 { @@ -248,45 +244,3 @@ pub fn layout(overlay_area: Rect) -> MinimapLayout { let track_area = Block::bordered().inner(area); MinimapLayout { area, track_area } } - -pub fn render( - f: &mut Frame, - overlay_area: Rect, - input_area: Rect, - alignment: &AlignmentModel, - ui: &UiState, -) { - let minimap_layout = layout(overlay_area); - let theme = &ui.theme.theme; - let styles = &ui.theme.styles; - let total_columns = alignment.view().column_count(); - - Clear.render(minimap_layout.area, f.buffer_mut()); - f.render_widget( - Block::bordered() - .border_style(styles.border) - .style(styles.panel_block), - minimap_layout.area, - ); - render_minimap_track( - f, - minimap_layout.track_area, - alignment, - theme, - total_columns, - ); - - if let Some(viewport_box) = highlight_box( - minimap_layout.track_area, - ui.viewport.window().col_range, - total_columns, - ) { - shade_highlight_box(f, viewport_box, theme); - } - - f.render_widget( - Paragraph::new(Line::from(Span::styled("Drag to pan", styles.text_dim))) - .style(styles.base_block), - input_area, - ); -} diff --git a/salti/src/ui/layers/mod.rs b/salti/src/ui/layers/mod.rs new file mode 100644 index 0000000..ed22c7b --- /dev/null +++ b/salti/src/ui/layers/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod minimap; +pub(crate) mod notification; +pub(crate) mod palette; +pub(crate) mod render; +pub(crate) mod state; diff --git a/salti/src/ui/notification.rs b/salti/src/ui/layers/notification.rs similarity index 88% rename from salti/src/ui/notification.rs rename to salti/src/ui/layers/notification.rs index 7e4b63e..3f37c86 100644 --- a/salti/src/ui/notification.rs +++ b/salti/src/ui/layers/notification.rs @@ -1,9 +1,12 @@ +use ratatui::{ + Frame, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::Paragraph, +}; + use crate::config::theme::ThemeStyles; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::Style; -use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NotificationLevel { diff --git a/salti/src/overlay/command_palette/command_definitions.rs b/salti/src/ui/layers/palette/command_definitions.rs similarity index 75% rename from salti/src/overlay/command_palette/command_definitions.rs rename to salti/src/ui/layers/palette/command_definitions.rs index d49b63d..ea97f79 100644 --- a/salti/src/overlay/command_palette/command_definitions.rs +++ b/salti/src/ui/layers/palette/command_definitions.rs @@ -1,11 +1,14 @@ -use super::command_runners::{ - run_check_update, run_clear_filter, run_clear_reference, run_consensus_method, run_diff_mode, - run_filter_gaps, run_filter_rows, run_jump_position, run_jump_sequence, run_load_alignment, - run_pin_sequence, run_quit, run_set_active_type, run_set_reference, run_theme, - run_toggle_translation, run_translation_frame, run_unpin_sequence, +use super::{ + command_runners::{ + run_check_update, run_clear_filter, run_clear_reference, run_consensus_method, + run_diff_mode, run_filter_constant, run_filter_gaps, run_filter_rows, run_jump_feature, + run_jump_position, run_jump_sequence, run_load_alignment, run_load_gff, run_pin_sequence, + run_quit, run_reload_as_protein, run_set_active_type, run_set_reference, run_theme, + run_toggle_translation, run_translation_frame, run_unpin_sequence, + }, + command_spec::{PaletteCommand, StaticCommand, TypableCommand}, + completers, }; -use super::command_spec::{PaletteCommand, StaticCommand, TypableCommand}; -use super::completers; /// Defines all commands available in the command palette. /// @@ -19,7 +22,7 @@ use super::completers; /// - `completer`: a function that provides autocompletion suggestions for the command's argument /// These are only used for typable commands. /// - `run`: the function that is called to execute the command. These are defined in -/// `overlay/command_palette/command_runners` +/// `ui/layers/palette/command_runners` pub(super) const COMMAND_SPECS: &[PaletteCommand] = &[ PaletteCommand::Typable(TypableCommand { name: "jump-position", @@ -37,6 +40,14 @@ pub(super) const COMMAND_SPECS: &[PaletteCommand] = &[ static_candidates: &[], run: run_jump_sequence, }), + PaletteCommand::Typable(TypableCommand { + name: "jump-feature", + help_text: "Jump to a GFF feature by name.", + aliases: &["jf"], + completer: Some(completers::features), + static_candidates: &[], + run: run_jump_feature, + }), PaletteCommand::Typable(TypableCommand { name: "pin-sequence", help_text: "Pin a sequence to the top of the alignment pane.", @@ -75,6 +86,14 @@ pub(super) const COMMAND_SPECS: &[PaletteCommand] = &[ static_candidates: &["0", "5", "10", "25", "50"], run: run_filter_gaps, }), + PaletteCommand::Typable(TypableCommand { + name: "filter-constant", + help_text: "Hide columns when a counted position reaches the given percentage. Gaps and unknowns are ignored. Use 0 to disable it.", + aliases: &[], + completer: None, + static_candidates: &["0", "70", "90", "95", "100"], + run: run_filter_constant, + }), PaletteCommand::Typable(TypableCommand { name: "set-reference", help_text: "Set the reference sequence used for diffs.", @@ -95,6 +114,14 @@ pub(super) const COMMAND_SPECS: &[PaletteCommand] = &[ aliases: &[], run: run_toggle_translation, }), + PaletteCommand::Typable(TypableCommand { + name: "reload-as-protein", + help_text: "Toggle reloading the DNA alignment as protein. Optionally pass a frame.", + aliases: &[], + completer: None, + static_candidates: &["1", "2", "3"], + run: run_reload_as_protein, + }), PaletteCommand::Static(StaticCommand { name: "check-update", help_text: "Check crates.io for a newer salti version.", @@ -160,4 +187,12 @@ pub(super) const COMMAND_SPECS: &[PaletteCommand] = &[ static_candidates: &["dna", "protein", "generic"], run: run_set_active_type, }), + PaletteCommand::Typable(TypableCommand { + name: "load-gff", + help_text: "Load a GFF annotation file to display features above the alignment.", + aliases: &[], + completer: Some(completers::filename), + static_candidates: &[], + run: run_load_gff, + }), ]; diff --git a/salti/src/overlay/command_palette/command_runners.rs b/salti/src/ui/layers/palette/command_runners.rs similarity index 57% rename from salti/src/overlay/command_palette/command_runners.rs rename to salti/src/ui/layers/palette/command_runners.rs index 0e576ac..c191f89 100644 --- a/salti/src/overlay/command_palette/command_runners.rs +++ b/salti/src/ui/layers/palette/command_runners.rs @@ -1,10 +1,11 @@ -use crate::command::Command; use anyhow::format_err; use tracing::warn; -use super::input::CommandPaletteState; -use super::input::VisibleSequence; -use super::utils::parse_argument; +use super::{ + input::{CommandPaletteState, VisibleSequence}, + utils::parse_argument, +}; +use crate::command::Command; fn ensure_no_argument(arguments: &str) -> anyhow::Result<()> { if parse_argument(arguments).is_some() { @@ -65,6 +66,27 @@ pub(super) fn run_toggle_translation( }) } +pub(super) fn run_reload_as_protein( + state: &CommandPaletteState, + arguments: &str, +) -> anyhow::Result { + run_command("reload-as-protein", arguments, || { + let frame = match parse_argument(arguments) { + Some(arg) => Some( + arg.parse() + .map_err(|_| format_err!("Invalid argument for reload-as-protein: {arg}"))?, + ), + None => None, + }; + if state.active_type != libmsa::AlignmentType::Dna && !state.is_reloaded_as_protein { + return Err(format_err!( + "reload-as-protein is only available for DNA alignments", + )); + } + Ok(Command::ReloadAsProtein { frame }) + }) +} + fn next_visible_column_index(visible_columns: &[usize], absolute_target: usize) -> Option { match visible_columns.binary_search(&absolute_target) { Ok(visible_index) => Some(visible_index), @@ -74,27 +96,36 @@ fn next_visible_column_index(visible_columns: &[usize], absolute_target: usize) } } +fn parse_percentage_argument(arguments: &str) -> anyhow::Result> { + let value = require_argument(arguments)?; + let Ok(percent) = value.parse::() else { + return Err(format_err!( + "Invalid argument: expected a percentage in 0..=100", + )); + }; + if !percent.is_finite() || !(0.0..=100.0).contains(&percent) { + return Err(format_err!( + "Invalid argument: expected a percentage in 0..=100", + )); + } + + Ok((percent != 0.0).then_some(percent / 100.0)) +} + pub(super) fn run_filter_gaps(_: &CommandPaletteState, arguments: &str) -> anyhow::Result { run_command("filter-gaps", arguments, || { - let value = require_argument(arguments)?; - let Ok(percent) = value.parse::() else { - return Err(format_err!( - "Invalid argument: expected a percentage in 0..=100", - )); - }; - if !percent.is_finite() || !(0.0..=100.0).contains(&percent) { - return Err(format_err!( - "Invalid argument: expected a percentage in 0..=100", - )); - } - - let max_gap_fraction = if percent == 0.0 { - None - } else { - Some(percent / 100.0) - }; + Ok(Command::SetGapFilter(parse_percentage_argument(arguments)?)) + }) +} - Ok(Command::SetGapFilter(max_gap_fraction)) +pub(super) fn run_filter_constant( + _: &CommandPaletteState, + arguments: &str, +) -> anyhow::Result { + run_command("filter-constant", arguments, || { + Ok(Command::SetConstantFilter(parse_percentage_argument( + arguments, + )?)) }) } @@ -158,6 +189,30 @@ pub(super) fn run_jump_sequence( }) } +pub(super) fn run_jump_feature( + state: &CommandPaletteState, + arguments: &str, +) -> anyhow::Result { + run_command("jump-feature", arguments, || { + let feature_name = require_argument(arguments)?; + let Some(gff_feature_targets) = state.gff_feature_targets.as_ref() else { + return Err(format_err!("No GFF file loaded")); + }; + let Some(feature_target) = gff_feature_targets + .iter() + .find(|feature_target| feature_target.feature_name.as_ref() == feature_name) + else { + return Err(format_err!("Feature not found: {feature_name}")); + }; + let Some(target_col) = feature_target.target_col else { + return Err(format_err!( + "No visible column at or after the requested position", + )); + }; + Ok(Command::JumpToPosition(target_col)) + }) +} + pub(super) fn run_pin_sequence( state: &CommandPaletteState, arguments: &str, @@ -278,9 +333,7 @@ pub(super) fn run_check_update( ) -> anyhow::Result { run_command("check-update", arguments, || { ensure_no_argument(arguments)?; - Ok(Command::CheckForUpdate { - show_success_message: true, - }) + Ok(Command::CheckForUpdateAndNotify) }) } @@ -291,17 +344,157 @@ pub(super) fn run_quit(_: &CommandPaletteState, arguments: &str) -> anyhow::Resu }) } +pub(super) fn run_load_gff(_: &CommandPaletteState, arguments: &str) -> anyhow::Result { + run_command("load-gff", arguments, || { + let path = require_argument(arguments)?; + Ok(Command::LoadGff { path }) + }) +} + #[cfg(test)] mod tests { + use std::ops::Range; + use super::*; + use crate::{ + core::{ + gff::{Feature, FeatureType, Gff, Strand}, + model::AlignmentModel, + }, + ui::layers::palette::input::{CommandPaletteSnapshot, GffFeatureTarget}, + }; + + fn raw(sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: "seq".to_string(), + sequence: sequence.to_vec(), + } + } + + fn gff_feature(name: &str, range: Range) -> Feature { + Feature { + name: name.to_string(), + kind: FeatureType::Gene, + range, + strand: Strand::Forward, + } + } + + fn model_with_sequence(sequence: &[u8]) -> AlignmentModel { + let alignment = libmsa::Alignment::new(vec![raw(sequence)]).unwrap(); + AlignmentModel::new(alignment).unwrap() + } fn palette_state_with_columns(visible_columns: Vec) -> CommandPaletteState { - CommandPaletteState::new( - Vec::new(), - Vec::new(), - libmsa::AlignmentType::Dna, + CommandPaletteState::new(CommandPaletteSnapshot { + selectable_sequences: Vec::new(), + pinned_sequences: Vec::new(), + active_type: libmsa::AlignmentType::Dna, + is_reloaded_as_protein: false, visible_columns, - ) + gff_feature_targets: None, + }) + } + + fn palette_state_with_gff_features(gff_features: Vec) -> CommandPaletteState { + CommandPaletteState::new(CommandPaletteSnapshot { + selectable_sequences: Vec::new(), + pinned_sequences: Vec::new(), + active_type: libmsa::AlignmentType::Dna, + is_reloaded_as_protein: false, + visible_columns: vec![0, 1, 2], + gff_feature_targets: Some(gff_features), + }) + } + + #[test] + fn jump_feature_returns_the_first_matching_feature_target() { + let state = palette_state_with_gff_features(vec![ + GffFeatureTarget { + feature_name: "gene-a".into(), + target_col: Some(2), + }, + GffFeatureTarget { + feature_name: "gene-a".into(), + target_col: Some(1), + }, + ]); + + let action = run_jump_feature(&state, "gene-a") + .expect("jump-feature should use the first matching feature in GFF order"); + + assert_eq!(action, Command::JumpToPosition(2)); + } + + #[test] + fn jump_feature_errors_when_no_gff_is_loaded() { + let state = palette_state_with_columns(vec![0, 1, 2]); + + let error = + run_jump_feature(&state, "gene-a").expect_err("jump-feature should require a GFF"); + + assert_eq!(error.to_string(), "No GFF file loaded"); + } + + #[test] + fn jump_feature_errors_when_the_feature_name_is_missing() { + let state = palette_state_with_gff_features(vec![GffFeatureTarget { + feature_name: "gene-a".into(), + target_col: Some(2), + }]); + + let error = run_jump_feature(&state, "gene-b") + .expect_err("jump-feature should reject unknown feature names"); + + assert_eq!(error.to_string(), "Feature not found: gene-b"); + } + + #[test] + fn jump_feature_uses_the_first_visible_column_inside_the_feature() { + let mut model = model_with_sequence(b"-A-CC--G"); + model.set_gap_filter(Some(0.0)).unwrap(); + let gff = Gff { + features: vec![gff_feature("gene-a", 2..6)], + }; + let state = CommandPaletteState::from_alignment(&model, Some(&gff)); + + let action = run_jump_feature(&state, "gene-a") + .expect("jump-feature should use the first visible feature column"); + + assert_eq!(action, Command::JumpToPosition(1)); + } + + #[test] + fn jump_feature_errors_when_all_feature_columns_are_hidden() { + let mut model = model_with_sequence(b"-A-CC--G"); + model.set_gap_filter(Some(0.0)).unwrap(); + let gff = Gff { + features: vec![gff_feature("gene-a", 5..7)], + }; + let state = CommandPaletteState::from_alignment(&model, Some(&gff)); + + let error = run_jump_feature(&state, "gene-a") + .expect_err("jump-feature should not jump outside the requested feature"); + + assert_eq!( + error.to_string(), + "No visible column at or after the requested position" + ); + } + + #[test] + fn jump_feature_maps_nucleotide_coordinates_into_reloaded_protein_columns() { + let mut model = model_with_sequence(b"ATGAAATTT"); + model.toggle_reload_as_protein(None).unwrap(); + let gff = Gff { + features: vec![gff_feature("gene-a", 3..9)], + }; + let state = CommandPaletteState::from_alignment(&model, Some(&gff)); + + let action = run_jump_feature(&state, "gene-a") + .expect("jump-feature should map into the current protein view"); + + assert_eq!(action, Command::JumpToPosition(1)); } #[test] @@ -375,6 +568,29 @@ mod tests { ); } + #[test] + fn filter_constant_parses_percentage_into_constant_fraction() { + let state = palette_state_with_columns(Vec::new()); + + let action = run_filter_constant(&state, "90").expect("percentage should parse"); + + assert!(matches!( + action, + Command::SetConstantFilter(Some(value)) + if (value - 0.9).abs() < f32::EPSILON + )); + } + + #[test] + fn filter_constant_zero_clears_the_constant_filter() { + let state = palette_state_with_columns(Vec::new()); + + let action = + run_filter_constant(&state, "0").expect("zero should disable the constant filter"); + + assert_eq!(action, Command::SetConstantFilter(None)); + } + #[test] fn set_active_type_accepts_alignment_type_name() { let state = palette_state_with_columns(Vec::new()); @@ -400,4 +616,18 @@ mod tests { "Invalid argument for set-sequence-type: rna" ); } + + #[test] + fn reload_as_protein_accepts_optional_frame() { + let state = palette_state_with_columns(Vec::new()); + + let action = run_reload_as_protein(&state, "2").expect("frame should parse"); + + assert_eq!( + action, + Command::ReloadAsProtein { + frame: Some(libmsa::ReadingFrame::Frame2), + } + ); + } } diff --git a/salti/src/overlay/command_palette/command_spec.rs b/salti/src/ui/layers/palette/command_spec.rs similarity index 99% rename from salti/src/overlay/command_palette/command_spec.rs rename to salti/src/ui/layers/palette/command_spec.rs index 37e80fd..ac5f02c 100644 --- a/salti/src/overlay/command_palette/command_spec.rs +++ b/salti/src/ui/layers/palette/command_spec.rs @@ -1,6 +1,5 @@ -use crate::command::Command; - use super::input::CommandPaletteState; +use crate::command::Command; pub(super) type CompleterFunc = fn(&CommandPaletteState, &str) -> Vec; pub(super) type RunnerFunc = fn(&CommandPaletteState, &str) -> anyhow::Result; diff --git a/salti/src/overlay/command_palette/completers.rs b/salti/src/ui/layers/palette/completers.rs similarity index 53% rename from salti/src/overlay/command_palette/completers.rs rename to salti/src/ui/layers/palette/completers.rs index 217481c..5629ca4 100644 --- a/salti/src/overlay/command_palette/completers.rs +++ b/salti/src/ui/layers/palette/completers.rs @@ -1,5 +1,4 @@ -use std::fs; -use std::path::PathBuf; +use std::{fs, path::Path}; use super::input::CommandPaletteState; @@ -18,6 +17,18 @@ pub(super) fn pinned_sequences(state: &CommandPaletteState, _: &str) -> Vec Vec { + state + .gff_feature_targets + .as_deref() + .map_or_else(Vec::new, |gff_feature_targets| { + gff_feature_targets + .iter() + .map(|gff_feature_target| gff_feature_target.feature_name.to_string()) + .collect() + }) +} + pub(super) fn filter_matches(state: &CommandPaletteState, arguments: &str) -> Vec { let regex_text = arguments.trim(); if regex_text.is_empty() { @@ -65,12 +76,12 @@ fn join_display_path(dir_prefix: &str, name: &str) -> String { pub(super) fn filename(_: &CommandPaletteState, arguments: &str) -> Vec { let (dir_prefix, name_prefix) = split_dir_and_prefix(arguments); let base_dir = if dir_prefix.is_empty() { - PathBuf::from(".") + Path::new(".") } else { - PathBuf::from(dir_prefix) + Path::new(dir_prefix) }; - let Ok(entries) = fs::read_dir(base_dir.as_path()) else { + let Ok(entries) = fs::read_dir(base_dir) else { return Vec::new(); }; @@ -78,16 +89,14 @@ pub(super) fn filename(_: &CommandPaletteState, arguments: &str) -> Vec for entry in entries.flatten() { let entry_name = entry.file_name(); - let Some(entry_name) = entry_name.to_str() else { - continue; - }; + let entry_name = entry_name.to_string_lossy(); if !entry_name.starts_with(name_prefix) { continue; } - let is_dir = entry.path().is_dir(); - let mut label = join_display_path(dir_prefix, entry_name); + let is_dir = entry.file_type().is_ok_and(|kind| kind.is_dir()); + let mut label = join_display_path(dir_prefix, &entry_name); if is_dir { label.push('/'); } @@ -97,3 +106,45 @@ pub(super) fn filename(_: &CommandPaletteState, arguments: &str) -> Vec matches.sort(); matches.into_iter().map(|(_, label)| label).collect() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::layers::palette::input::{CommandPaletteSnapshot, GffFeatureTarget}; + + fn palette_state_with_gff_features( + gff_feature_targets: Option>, + ) -> CommandPaletteState { + CommandPaletteState::new(CommandPaletteSnapshot { + selectable_sequences: Vec::new(), + pinned_sequences: Vec::new(), + active_type: libmsa::AlignmentType::Dna, + is_reloaded_as_protein: false, + visible_columns: vec![0, 1, 2], + gff_feature_targets, + }) + } + + #[test] + fn features_returns_gff_feature_names_in_gff_order() { + let state = palette_state_with_gff_features(Some(vec![ + GffFeatureTarget { + feature_name: "gene-b".into(), + target_col: Some(1), + }, + GffFeatureTarget { + feature_name: "gene-a".into(), + target_col: Some(2), + }, + ])); + + assert_eq!(features(&state, ""), vec!["gene-b", "gene-a"]); + } + + #[test] + fn features_returns_an_empty_list_without_a_gff() { + let state = palette_state_with_gff_features(None); + + assert!(features(&state, "").is_empty()); + } +} diff --git a/salti/src/overlay/command_palette/input.rs b/salti/src/ui/layers/palette/input.rs similarity index 75% rename from salti/src/overlay/command_palette/input.rs rename to salti/src/ui/layers/palette/input.rs index c227b56..ad6d6a3 100644 --- a/salti/src/overlay/command_palette/input.rs +++ b/salti/src/ui/layers/palette/input.rs @@ -1,17 +1,26 @@ -use anyhow::format_err; use std::sync::Arc; +use anyhow::format_err; use crossterm::event::{KeyCode, KeyEvent}; use libmsa::AlignmentType; -use crate::command::Command; -use crate::core::model::AlignmentModel; -use crate::core::search::{Direction, FilterMode, SearchableList}; -use crate::ui::notification::{Notification, NotificationLevel}; - -use super::command_definitions::COMMAND_SPECS; -use super::command_spec::{PaletteCommand, TypableCommand}; -use super::utils::parse_argument; +use super::{ + command_definitions::COMMAND_SPECS, + command_spec::{PaletteCommand, TypableCommand}, + utils::parse_argument, +}; +use crate::{ + command::Command, + core::{ + gff::Gff, + model::AlignmentModel, + search::{Direction, FilterMode, SearchableList}, + }, + ui::{ + features::FeatureMap, + layers::notification::{Notification, NotificationLevel}, + }, +}; #[derive(Debug, Clone, Copy)] pub(super) enum PaletteState { @@ -25,6 +34,21 @@ pub struct VisibleSequence { pub sequence_name: Arc, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GffFeatureTarget { + pub(crate) feature_name: Arc, + pub(crate) target_col: Option, +} + +pub(crate) struct CommandPaletteSnapshot { + pub(crate) selectable_sequences: Vec, + pub(crate) pinned_sequences: Vec, + pub(crate) active_type: AlignmentType, + pub(crate) is_reloaded_as_protein: bool, + pub(crate) visible_columns: Vec, + pub(crate) gff_feature_targets: Option>, +} + #[derive(Debug)] pub struct CommandPaletteState { pub(super) command_input: String, @@ -35,19 +59,23 @@ pub struct CommandPaletteState { pub(super) selectable_sequences: Vec, pub(super) pinned_sequences: Vec, pub(super) active_type: AlignmentType, + pub(super) is_reloaded_as_protein: bool, pub(super) visible_columns: Vec, + pub(super) gff_feature_targets: Option>, } impl CommandPaletteState { pub fn empty() -> Self { - Self::new( - Vec::new(), - Vec::new(), - libmsa::AlignmentType::Generic, - Vec::new(), - ) + Self::new(CommandPaletteSnapshot { + selectable_sequences: Vec::new(), + pinned_sequences: Vec::new(), + active_type: libmsa::AlignmentType::Generic, + is_reloaded_as_protein: false, + visible_columns: Vec::new(), + gff_feature_targets: None, + }) } - pub fn from_alignment(alignment: &AlignmentModel) -> Self { + pub fn from_alignment(alignment: &AlignmentModel, gff: Option<&Gff>) -> Self { let mut selectable_sequences: Vec = (0..alignment.view().row_count()) .filter_map(|rel| { let sequence = alignment.view().sequence(rel)?; @@ -80,20 +108,39 @@ impl CommandPaletteState { }) .collect(); - Self::new( + let gff_feature_targets = gff.map(|gff| { + let mapping = FeatureMap::for_alignment(alignment); + gff.features + .iter() + .map(|feature| GffFeatureTarget { + feature_name: feature.name.as_str().into(), + target_col: mapping + .map_feature(alignment.view(), feature) + .map(|range| range.start), + }) + .collect() + }); + + Self::new(CommandPaletteSnapshot { selectable_sequences, pinned_sequences, - alignment.base().active_type(), - alignment.view().absolute_column_ids().collect(), - ) + active_type: alignment.base().active_type(), + is_reloaded_as_protein: alignment.is_reloaded_as_protein(), + visible_columns: alignment.view().absolute_column_ids().collect(), + gff_feature_targets, + }) } - pub fn new( - selectable_sequences: Vec, - pinned_sequences: Vec, - active_type: AlignmentType, - visible_columns: Vec, - ) -> Self { + pub(crate) fn new(init: CommandPaletteSnapshot) -> Self { + let CommandPaletteSnapshot { + selectable_sequences, + pinned_sequences, + active_type, + is_reloaded_as_protein, + visible_columns, + gff_feature_targets, + } = init; + let mut command_list = SearchableList::new(FilterMode::Fuzzy, None); command_list.set_items(display_command_names()); let completion_list = SearchableList::new(FilterMode::Fuzzy, None); @@ -107,7 +154,9 @@ impl CommandPaletteState { selectable_sequences, pinned_sequences, active_type, + is_reloaded_as_protein, visible_columns, + gff_feature_targets, } } @@ -118,18 +167,17 @@ impl CommandPaletteState { Some(command) } - fn parse_command_input(&self) -> Option<(String, Option)> { - let input = self.command_input.trim(); + fn parse_command_input(input: &str) -> Option<(&str, Option<&str>)> { + let input = input.trim(); if input.is_empty() { return None; } if let Some((command_name, rest)) = input.split_once(char::is_whitespace) { let arguments = rest.trim(); - let arguments = (!arguments.is_empty()).then_some(arguments.to_string()); - Some((command_name.to_string(), arguments)) + Some((command_name, (!arguments.is_empty()).then_some(arguments))) } else { - Some((input.to_string(), None)) + Some((input, None)) } } @@ -248,10 +296,11 @@ impl CommandPaletteState { } fn submit_command_selection(&mut self) -> Vec { - let Some((command_name, arguments)) = self.parse_command_input() else { + let input = std::mem::take(&mut self.command_input); + let Some((command_name, arguments)) = Self::parse_command_input(input.as_str()) else { return self.command_error(&format_err!("No command selected")); }; - let Some(spec) = resolve_command(command_name.as_str()) else { + let Some(spec) = resolve_command(command_name) else { return self.command_error(&format_err!("Unknown command")); }; let command_selected = self.command_list.selected_display_index().is_some(); @@ -262,7 +311,7 @@ impl CommandPaletteState { if spec.typable().is_none() { return self.command_error(&format_err!("Expected 0 arguments, got 1")); } - return match spec.run(self, arguments.as_str()) { + return match spec.run(self, arguments) { Ok(action) => self.close_palette_with(action), Err(error) => self.command_error(&error), }; @@ -386,6 +435,20 @@ impl CommandPaletteState { } } +fn display_command_names() -> Vec { + COMMAND_SPECS + .iter() + .map(|spec| spec.name().to_string()) + .collect() +} + +fn resolve_command(name: &str) -> Option { + COMMAND_SPECS + .iter() + .copied() + .find(|spec| spec.name() == name || spec.aliases().contains(&name)) +} + #[cfg(test)] mod tests { use super::*; @@ -396,12 +459,14 @@ mod tests { #[test] fn submit_returns_expected_command() { - let mut palette = CommandPaletteState::new( - Vec::new(), - Vec::new(), - libmsa::AlignmentType::Dna, - vec![0, 3, 4], - ); + let mut palette = CommandPaletteState::new(CommandPaletteSnapshot { + selectable_sequences: Vec::new(), + pinned_sequences: Vec::new(), + active_type: libmsa::AlignmentType::Dna, + is_reloaded_as_protein: false, + visible_columns: vec![0, 3, 4], + gff_feature_targets: None, + }); palette.command_input = "jump-position 2".to_string(); let commands = palette.handle_key_event(key(KeyCode::Enter)); @@ -412,6 +477,29 @@ mod tests { ); } + #[test] + fn submit_jump_feature_alias_returns_the_expected_command() { + let mut palette = CommandPaletteState::new(CommandPaletteSnapshot { + selectable_sequences: Vec::new(), + pinned_sequences: Vec::new(), + active_type: libmsa::AlignmentType::Dna, + is_reloaded_as_protein: false, + visible_columns: vec![0, 1, 2], + gff_feature_targets: Some(vec![GffFeatureTarget { + feature_name: "gene-a".into(), + target_col: Some(2), + }]), + }); + palette.command_input = "jf gene-a".to_string(); + + let commands = palette.handle_key_event(key(KeyCode::Enter)); + + assert_eq!( + commands, + vec![Command::JumpToPosition(2), Command::CloseOverlay] + ); + } + #[test] fn submit_success_appends_close_command_palette() { let mut palette = CommandPaletteState::empty(); @@ -441,17 +529,3 @@ mod tests { ); } } - -fn display_command_names() -> Vec { - COMMAND_SPECS - .iter() - .map(|spec| spec.name().to_string()) - .collect() -} - -fn resolve_command(name: &str) -> Option { - COMMAND_SPECS - .iter() - .copied() - .find(|spec| spec.name() == name || spec.aliases().contains(&name)) -} diff --git a/salti/src/overlay/command_palette/mod.rs b/salti/src/ui/layers/palette/mod.rs similarity index 100% rename from salti/src/overlay/command_palette/mod.rs rename to salti/src/ui/layers/palette/mod.rs diff --git a/salti/src/overlay/command_palette/ui.rs b/salti/src/ui/layers/palette/ui.rs similarity index 94% rename from salti/src/overlay/command_palette/ui.rs rename to salti/src/ui/layers/palette/ui.rs index 625d4a5..6720731 100644 --- a/salti/src/overlay/command_palette/ui.rs +++ b/salti/src/ui/layers/palette/ui.rs @@ -1,15 +1,18 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::Styled; -use ratatui::text::Line; -use ratatui::widgets::{Block, Clear, Paragraph, Widget}; - +use ratatui::{ + Frame, + layout::Rect, + style::Styled, + text::{Line, Span}, + widgets::{Block, Clear, Paragraph, Widget}, +}; + +use super::{ + command_spec::PaletteCommand, + input::{CommandPaletteState, PaletteState}, + utils::{pad_label, wrap_text}, +}; use crate::core::search::SearchableList; -use super::command_spec::PaletteCommand; -use super::input::{CommandPaletteState, PaletteState}; -use super::utils::{pad_label, wrap_text}; - /// maximum number of rows shown in the command/preview grid at once. const COMMAND_GRID_MAX_VISIBLE_ROWS: usize = 6; /// maximum number of columns shown in the command/preview grid. @@ -235,14 +238,18 @@ impl CommandPaletteState { } fn render_input(&self, f: &mut Frame, area: Rect, theme: &crate::config::theme::ThemeStyles) { - let input = match self.phase { - PaletteState::Command => format!(":{}", self.command_input), - PaletteState::Argument { .. } => { - format!(":{} {}", self.command_input, self.argument_input) - } - }; + let mut spans = vec![ + Span::styled(":", theme.warning), + Span::styled(self.command_input.as_str(), theme.warning), + ]; + + if matches!(self.phase, PaletteState::Argument { .. }) { + spans.push(Span::styled(" ", theme.warning)); + spans.push(Span::styled(self.argument_input.as_str(), theme.warning)); + } - let line = Line::from(format!("{input}█").set_style(theme.warning)); + spans.push(Span::styled("█", theme.warning)); + let line = Line::from(spans); f.render_widget(Paragraph::new(line).style(theme.base_block), area); } diff --git a/salti/src/overlay/command_palette/utils.rs b/salti/src/ui/layers/palette/utils.rs similarity index 100% rename from salti/src/overlay/command_palette/utils.rs rename to salti/src/ui/layers/palette/utils.rs diff --git a/salti/src/overlay/render.rs b/salti/src/ui/layers/render.rs similarity index 56% rename from salti/src/overlay/render.rs rename to salti/src/ui/layers/render.rs index 39a6b96..72867d3 100644 --- a/salti/src/overlay/render.rs +++ b/salti/src/ui/layers/render.rs @@ -1,12 +1,12 @@ -use crate::core::model::AlignmentModel; -use crate::ui::notification::render_notification; -use crate::ui::ui_state::UiState; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::widgets::Block; +use ratatui::{Frame, layout::Rect, widgets::Block}; -use super::minimap; -use super::overlay_state::ActiveOverlay; +use crate::{ + core::model::AlignmentModel, + ui::{ + layers::{minimap::Minimap, notification::render_notification, state::ActiveLayer}, + ui_state::UiState, + }, +}; pub fn render_overlays( f: &mut Frame, @@ -15,19 +15,19 @@ pub fn render_overlays( alignment: Option<&AlignmentModel>, ui: &UiState, ) { - match &ui.overlay.active_overlay { - Some(ActiveOverlay::Minimap(_)) => { + match &ui.layers.active { + Some(ActiveLayer::Minimap(_)) => { if let Some(alignment) = alignment { - minimap::render(f, content_area, input_area, alignment, ui); + f.render_widget(Minimap::new(input_area, alignment, ui), content_area); } } - Some(ActiveOverlay::Palette(palette)) => { + Some(ActiveLayer::Palette(palette)) => { palette.render(f, content_area, input_area, &ui.theme.styles); } None => (), } - if ui.overlay.active_overlay.is_none() { + if ui.layers.active.is_none() { match ui.notification.as_ref() { Some(notification) => { render_notification(f, input_area, notification, &ui.theme.styles); diff --git a/salti/src/ui/layers/state.rs b/salti/src/ui/layers/state.rs new file mode 100644 index 0000000..71c7165 --- /dev/null +++ b/salti/src/ui/layers/state.rs @@ -0,0 +1,29 @@ +use crate::ui::layers::{minimap::MinimapState, palette::CommandPaletteState}; + +#[derive(Debug)] +pub enum ActiveLayer { + Palette(Box), + Minimap(MinimapState), +} + +#[derive(Debug, Default)] +pub struct LayerState { + pub active: Option, +} + +impl LayerState { + pub fn open_palette(&mut self, palette: CommandPaletteState) { + self.active = Some(ActiveLayer::Palette(Box::new(palette))); + } + + pub fn toggle_minimap(&mut self) { + self.active = match self.active.take() { + Some(ActiveLayer::Minimap(_)) => None, + _ => Some(ActiveLayer::Minimap(MinimapState::default())), + }; + } + + pub fn close_active(&mut self) { + self.active = None; + } +} diff --git a/salti/src/ui/layout.rs b/salti/src/ui/layout.rs index 0521d00..bd01979 100644 --- a/salti/src/ui/layout.rs +++ b/salti/src/ui/layout.rs @@ -1,5 +1,7 @@ -use ratatui::layout::{Rect, Spacing}; -use ratatui::macros::{horizontal, vertical}; +use ratatui::{ + layout::{Rect, Spacing}, + macros::{horizontal, vertical}, +}; /// fixed height (rows) for the bottom consensus pane. /// the remaining vertical space is used for the alignment pane. @@ -10,6 +12,32 @@ pub const RULER_HEIGHT_ROWS: u16 = 2; /// the remaining horizontal space is used for sequence content. const SEQUENCE_ID_PANE_WIDTH_PERCENT: u16 = 20; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AlignmentHeaderLayout { + pub local_feature_rows: u16, + pub ruler_rows: u16, +} + +impl AlignmentHeaderLayout { + pub(crate) fn without_features() -> Self { + Self { + local_feature_rows: 0, + ruler_rows: RULER_HEIGHT_ROWS, + } + } + + pub(crate) fn with_features(local_feature_rows: u16) -> Self { + Self { + local_feature_rows, + ruler_rows: RULER_HEIGHT_ROWS, + } + } + + pub(crate) fn height(self) -> u16 { + self.local_feature_rows.saturating_add(self.ruler_rows) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PinnedSectionLayout { pub pinned_rendered: usize, @@ -66,13 +94,24 @@ pub struct AppLayout { pub sequence_id_pane: Rect, pub alignment_pane: Rect, pub alignment_pane_sequence_rows: Rect, + pub alignment_header: AlignmentHeaderLayout, pub consensus_sequence_id_pane: Rect, pub consensus_alignment_pane: Rect, + pub gff_info_pane: Rect, + pub gff_pane: Rect, + pub gff_pane_rows: Rect, } impl AppLayout { - pub fn new(content_area: Rect) -> Self { - let [alignment_area, consensus_area] = content_area + pub fn new( + content_area: Rect, + gff_height: u16, + alignment_header: AlignmentHeaderLayout, + ) -> Self { + let [gff_area, main_area] = + content_area.layout(&vertical![==gff_height, *=1].spacing(Spacing::Overlap(1))); + + let [alignment_area, consensus_area] = main_area .layout(&vertical![*=1, ==CONSENSUS_PANE_HEIGHT_ROWS].spacing(Spacing::Overlap(1))); let [sequence_id_pane_area, alignment_pane_area] = alignment_area.layout( @@ -84,16 +123,40 @@ impl AppLayout { ] = consensus_area.layout( &horizontal![==SEQUENCE_ID_PANE_WIDTH_PERCENT%, *=1].spacing(Spacing::Overlap(1)), ); - let [_, sequence_rows_area] = ratatui::widgets::Block::bordered() - .inner(alignment_pane_area) - .layout(&vertical![==RULER_HEIGHT_ROWS, *=1]); + let inner_alignment_pane = ratatui::widgets::Block::bordered().inner(alignment_pane_area); + let [_, _, sequence_rows_area] = inner_alignment_pane.layout(&vertical![ + ==alignment_header.local_feature_rows, + ==alignment_header.ruler_rows, + *=1 + ]); + + let [gff_info_pane_area, gff_pane_area] = gff_area.layout( + &horizontal![==SEQUENCE_ID_PANE_WIDTH_PERCENT%, *=1].spacing(Spacing::Overlap(1)), + ); + let gff_pane_rows = if gff_pane_area.width > 2 && gff_pane_area.height > 2 { + ratatui::widgets::Block::bordered().inner(gff_pane_area) + } else { + Rect::default() + }; Self { sequence_id_pane: sequence_id_pane_area, alignment_pane: alignment_pane_area, alignment_pane_sequence_rows: sequence_rows_area, + alignment_header, consensus_sequence_id_pane: consensus_sequence_id_pane_area, consensus_alignment_pane: consensus_alignment_pane_area, + gff_info_pane: gff_info_pane_area, + gff_pane: gff_pane_area, + gff_pane_rows, } } } + +pub fn gff_pane_height(feature_row_count: usize) -> u16 { + if feature_row_count == 0 { + return 0; + } + let inner = u16::try_from(feature_row_count).unwrap_or(u16::MAX.saturating_sub(3)); + inner.saturating_add(3) +} diff --git a/salti/src/ui/mod.rs b/salti/src/ui/mod.rs index 84a59d2..520fc78 100644 --- a/salti/src/ui/mod.rs +++ b/salti/src/ui/mod.rs @@ -1,11 +1,9 @@ -pub(crate) mod alignment_pane; -pub(crate) mod consensus_pane; -pub(crate) mod frame; +pub(crate) mod features; +pub(crate) mod layers; pub(crate) mod layout; -pub(crate) mod notification; +pub(crate) mod panes; pub(crate) mod render; pub(crate) mod rows; pub(crate) mod selection; -pub(crate) mod sequence_id_pane; pub(crate) mod ui_state; pub(crate) mod utils; diff --git a/salti/src/ui/panes/alignment.rs b/salti/src/ui/panes/alignment.rs new file mode 100644 index 0000000..aa90517 --- /dev/null +++ b/salti/src/ui/panes/alignment.rs @@ -0,0 +1,733 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + macros::vertical, + style::Styled, + symbols::merge::MergeStrategy, + text::Line, + widgets::{Block, Paragraph, Widget}, +}; + +use crate::{ + core::{ + codon::TranslatedDiffRange, + gff::Gff, + model::{AlignmentModel, DiffMode}, + stats_cache::ColumnStatsCache, + viewport::{Viewport, ViewportWindow}, + }, + ui::{ + layout::{AlignmentHeaderLayout, PinnedSectionLayout, pinned_section_layout}, + panes::{local_feature_track::LocalFeatureTrack, ruler::Ruler}, + rows::{RowRenderMode, format_row_view_spans, format_translated_row_spans, visible_bytes}, + ui_state::ThemeState, + }, +}; + +const SCROLLBAR_THUMB_WIDTH: usize = 3; +const SCROLLBAR_THUMB_MIN_WIDTH: usize = 1; + +pub(crate) struct AlignmentPane<'a> { + pub(crate) alignment: &'a AlignmentModel, + pub(crate) viewport: &'a Viewport, + pub(crate) metrics: &'a ColumnStatsCache, + pub(crate) gff: Option<&'a Gff>, + pub(crate) header: AlignmentHeaderLayout, + pub(crate) theme: &'a ThemeState, +} + +impl Widget for AlignmentPane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .border_style(self.theme.styles.border) + .style(self.theme.styles.base_block) + .merge_borders(MergeStrategy::Exact); + let inner_area = block.inner(area); + block.render(area, buf); + + let [local_feature_area, ruler_area, sequence_rows_area] = inner_area.layout(&vertical![ + ==self.header.local_feature_rows, + ==self.header.ruler_rows, + *=1 + ]); + let window = self.viewport.window(); + + if let Some(gff) = self.gff { + LocalFeatureTrack { + gff, + alignment: self.alignment, + window: &window, + theme: self.theme, + } + .render(local_feature_area, buf); + } + Ruler { + alignment: self.alignment, + window: &window, + theme: self.theme, + } + .render(ruler_area, buf); + render_sequence_rows( + self.alignment, + &window, + self.metrics, + sequence_rows_area, + self.theme, + buf, + ); + render_scrollbar( + self.alignment, + self.viewport, + &window, + self.theme, + area, + buf, + ); + } +} + +fn raw_render_mode<'a>( + alignment: &AlignmentModel, + reference_bytes: Option<&'a [u8]>, + consensus_bytes: Option<&'a [u8]>, +) -> RowRenderMode<'a> { + let diff_against = match alignment.diff_mode { + DiffMode::Off => None, + DiffMode::Reference => reference_bytes, + DiffMode::Consensus => consensus_bytes, + }; + + RowRenderMode { + alignment_type: alignment.base().active_type(), + diff_against, + } +} + +fn translated_diff_range<'a>( + diff_mode: DiffMode, + protein_range_start: usize, + reference_bytes: Option<&'a [u8]>, + consensus_bytes: Option<&'a [u8]>, +) -> Option> { + match diff_mode { + DiffMode::Off => None, + DiffMode::Reference => { + reference_bytes.map(|bytes| TranslatedDiffRange::new(protein_range_start, bytes)) + } + DiffMode::Consensus => { + consensus_bytes.map(|bytes| TranslatedDiffRange::new(protein_range_start, bytes)) + } + } +} + +fn emit_band_rows( + lines: &mut Vec>, + alignment: &AlignmentModel, + window: &ViewportWindow, + band_layout: &PinnedSectionLayout, + area_width: u16, + theme: &ThemeState, + render_row: &mut dyn FnMut(usize) -> Option>, +) { + for &absolute_row in alignment + .rows() + .pinned() + .iter() + .take(band_layout.pinned_rendered) + { + if let Some(line) = render_row(absolute_row) { + lines.push(line); + } + } + + if band_layout.divider_height == 1 { + lines.push(Line::from( + "─" + .repeat(area_width as usize) + .set_style(theme.styles.border), + )); + } + + for relative_row in window.row_range.clone() { + let Some(absolute_row) = alignment.view().absolute_row_id(relative_row) else { + continue; + }; + if let Some(line) = render_row(absolute_row) { + lines.push(line); + } + } +} + +fn build_sequence_row_lines( + alignment: &AlignmentModel, + window: &ViewportWindow, + metrics: &ColumnStatsCache, + area: Rect, + theme: &ThemeState, +) -> Vec> { + let band_layout = pinned_section_layout(alignment.rows().pinned().len(), area.height as usize); + let mut lines = Vec::with_capacity( + band_layout.pinned_rendered + band_layout.divider_height + window.row_range.len(), + ); + + if let Some(overlay) = alignment.translation_overlay() + && let Some(translated) = alignment.translated_view() + { + let protein_range = overlay.visible_protein_range(&window.col_range); + let reference_bytes: Option> = protein_range.clone().and_then(|protein_range| { + alignment + .rows() + .reference() + .and_then(|abs_row| translated.project_absolute_row(abs_row)) + .and_then(|sequence| { + let bytes = sequence.bytes_range(protein_range).ok()?; + Some(bytes.map(|(_, byte)| byte).collect()) + }) + }); + let consensus_bytes: Option> = protein_range.clone().and_then(|protein_range| { + protein_range + .clone() + .map(|protein_col: usize| { + metrics + .translated_summary_at(overlay.frame, protein_col) + .map(|summary| summary.consensus.unwrap_or(b' ')) + }) + .collect() + }); + let diff_against = protein_range.as_ref().and_then(|protein_range| { + translated_diff_range( + alignment.diff_mode, + protein_range.start, + reference_bytes.as_deref(), + consensus_bytes.as_deref(), + ) + }); + + emit_band_rows( + &mut lines, + alignment, + window, + &band_layout, + area.width, + theme, + &mut |absolute_row| { + let sequence = translated.project_absolute_row(absolute_row)?; + let spans = format_translated_row_spans( + sequence, + &window.col_range, + &overlay, + &theme.theme.sequence, + diff_against, + ); + Some(Line::from(spans)) + }, + ); + + return lines; + } + + let reference_bytes: Option> = alignment + .rows() + .reference() + .and_then(|abs_row| alignment.view().project_absolute_row(abs_row)) + .map(|sequence| visible_bytes(sequence, &window.col_range)); + let consensus_bytes: Option> = window + .col_range + .clone() + .map(|relative_col| { + metrics + .raw_summary_at(relative_col) + .map(|summary| summary.consensus.unwrap_or(b' ')) + }) + .collect(); + let render_mode = raw_render_mode( + alignment, + reference_bytes.as_deref(), + consensus_bytes.as_deref(), + ); + + emit_band_rows( + &mut lines, + alignment, + window, + &band_layout, + area.width, + theme, + &mut |absolute_row| { + let projected_row = alignment.view().project_absolute_row(absolute_row)?; + let spans = format_row_view_spans( + projected_row, + &window.col_range, + &theme.theme.sequence, + render_mode, + ); + Some(Line::from(spans)) + }, + ); + + lines +} + +fn render_sequence_rows( + alignment: &AlignmentModel, + window: &ViewportWindow, + metrics: &ColumnStatsCache, + area: Rect, + theme: &ThemeState, + buf: &mut Buffer, +) { + let lines = build_sequence_row_lines(alignment, window, metrics, area, theme); + Paragraph::new(lines) + .style(theme.styles.base_block) + .render(area, buf); +} + +fn render_scrollbar( + alignment: &AlignmentModel, + viewport: &Viewport, + window: &ViewportWindow, + theme: &ThemeState, + area: Rect, + buf: &mut Buffer, +) { + if area.width < 2 || area.height == 0 { + return; + } + + let total_columns = alignment.view().column_count(); + let visible_columns = window.col_range.len(); + if total_columns <= visible_columns { + return; + } + + let width = area.width.saturating_sub(2) as usize; + let max_index = total_columns.saturating_sub(1); + let col_offset = viewport.window().col_range.start; + let percent = col_offset + .saturating_mul(100) + .checked_div(max_index) + .unwrap_or(0); + let track_max = width.saturating_sub(1); + let thumb_index = if track_max == 0 { + 0 + } else { + (percent * track_max) / 100 + }; + let scrollbar_area = Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(1), + width: area.width.saturating_sub(2), + height: 1, + }; + let thumb_width = if SCROLLBAR_THUMB_WIDTH <= width { + SCROLLBAR_THUMB_WIDTH + } else { + SCROLLBAR_THUMB_MIN_WIDTH + }; + let thumb_start = thumb_index.saturating_sub(thumb_width / 2); + let thumb_end = (thumb_start + thumb_width).min(width); + let thumb_y = scrollbar_area.y; + let thumb_colour = theme.theme.accent_alt; + + for offset in thumb_start..thumb_end { + let thumb_x = scrollbar_area.x + offset as u16; + if let Some(cell) = buf.cell_mut((thumb_x, thumb_y)) { + let track_colour = cell.fg; + cell.set_char('▬'); + cell.set_fg(thumb_colour); + cell.set_bg(track_colour); + } + } +} + +#[cfg(test)] +mod tests { + use ratatui::buffer::Buffer; + + use super::*; + use crate::{ + core::{ + gff::{Feature, FeatureType, Gff, Strand}, + model::StatsView, + stats_cache::StatsJobResult, + }, + ui::layout::AppLayout, + }; + + fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: id.to_string(), + sequence: sequence.to_vec(), + } + } + + fn alignment_model(sequences: Vec) -> AlignmentModel { + let alignment = libmsa::Alignment::new(sequences).unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + fn metrics_with( + view: StatsView, + consensus: &[u8], + conservation: Option, + ) -> ColumnStatsCache { + let mut cache = ColumnStatsCache::default(); + match view { + StatsView::Raw => cache.init(consensus.len()), + StatsView::Translated(frame) => { + cache.init(consensus.len() * 3); + let _ = + cache.translated_chunks_to_spawn(&(0..consensus.len()), frame, consensus.len()); + } + } + + let summaries = consensus + .iter() + .enumerate() + .map(|(position, &byte)| libmsa::ColumnSummary { + position, + consensus: Some(byte), + conservation, + }) + .collect(); + let generation = cache.generation; + let chunk_idx = 0; + let stored = cache.store(StatsJobResult { + generation, + chunk_idx, + view, + summaries: Ok(summaries), + }); + assert!(stored); + cache + } + + fn render_alignment_pane_text( + alignment: &AlignmentModel, + stats_cache: &ColumnStatsCache, + area: Rect, + row_offset: usize, + col_offset: usize, + ) -> String { + render_alignment_pane_text_with_gff( + alignment, + stats_cache, + None, + area, + row_offset, + col_offset, + ) + } + + fn render_alignment_pane_text_with_gff( + alignment: &AlignmentModel, + stats_cache: &ColumnStatsCache, + gff: Option<&Gff>, + area: Rect, + row_offset: usize, + col_offset: usize, + ) -> String { + let mut buffer = Buffer::empty(area); + let header = match gff { + Some(gff) => { + let probe_layout = + AppLayout::new(area, 0, AlignmentHeaderLayout::without_features()); + let col_range = col_offset + ..col_offset + .saturating_add(probe_layout.alignment_pane_sequence_rows.width as usize) + .min(alignment.view().column_count()); + let local_rows = crate::ui::panes::local_feature_track::local_feature_row_count( + gff, alignment, &col_range, + ); + AlignmentHeaderLayout::with_features(local_rows as u16) + } + None => AlignmentHeaderLayout::without_features(), + }; + let layout = AppLayout::new(area, 0, header); + let mut viewport = Viewport::default(); + viewport.update_dimensions( + layout.alignment_pane_sequence_rows.width as usize, + layout.alignment_pane_sequence_rows.height as usize, + 0, + ); + viewport.set_bounds( + alignment.view().row_count(), + alignment.view().column_count(), + alignment.base().max_id_len(), + ); + viewport.offsets.rows = row_offset; + viewport.offsets.cols = col_offset; + + let theme = ThemeState::default(); + AlignmentPane { + alignment, + viewport: &viewport, + metrics: stats_cache, + gff, + header: layout.alignment_header, + theme: &theme, + } + .render(layout.alignment_pane, &mut buffer); + + buffer_text(&buffer, layout.alignment_pane) + } + + fn buffer_text(buffer: &Buffer, area: Rect) -> String { + let mut lines = Vec::new(); + + for y in area.top()..area.bottom() { + let mut line = String::new(); + for x in area.left()..area.right() { + let symbol = buffer[(x, y)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + while line.ends_with(' ') { + line.pop(); + } + lines.push(line); + } + + while matches!(lines.last(), Some(last) if last.is_empty()) { + lines.pop(); + } + + lines.join("\n") + } + + #[test] + fn alignment_pane_basic_snapshot() { + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCAT"), + ]); + + insta::assert_snapshot!( + "alignment_pane_basic", + render_alignment_pane_text( + &alignment, + &ColumnStatsCache::default(), + Rect::new(0, 0, 100, 12), + 0, + 0, + ) + ); + } + + #[test] + fn alignment_pane_reserves_blank_local_feature_row_snapshot() { + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCAT"), + ]); + let gff = Gff { + features: vec![Feature { + name: "Offscreen".to_string(), + kind: FeatureType::Gene, + range: 30..40, + strand: Strand::Forward, + }], + }; + + insta::assert_snapshot!( + "alignment_pane_blank_local_feature_track", + render_alignment_pane_text_with_gff( + &alignment, + &ColumnStatsCache::default(), + Some(&gff), + Rect::new(0, 0, 100, 12), + 0, + 0, + ) + ); + } + + #[test] + fn alignment_pane_with_local_feature_track_snapshot() { + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCAT"), + ]); + let gff = Gff { + features: vec![Feature { + name: "Spike".to_string(), + kind: FeatureType::Gene, + range: 2..14, + strand: Strand::Forward, + }], + }; + + insta::assert_snapshot!( + "alignment_pane_with_local_feature_track", + render_alignment_pane_text_with_gff( + &alignment, + &ColumnStatsCache::default(), + Some(&gff), + Rect::new(0, 0, 100, 12), + 0, + 0, + ) + ); + } + + #[test] + fn alignment_pane_pinned_and_fragmented_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CAT---CATCATCATCAT"), + raw("seq2", b"CAT---CATCATCATCAT"), + raw("seq3", b"CAT---CATCATCATCAT"), + raw("seq4", b"CAT---CATCATCATCAT"), + ]); + alignment.pin(1).unwrap(); + alignment.pin(3).unwrap(); + alignment.set_gap_filter(Some(0.5)).unwrap(); + + insta::assert_snapshot!( + "alignment_pane_pinned_and_fragmented", + render_alignment_pane_text( + &alignment, + &ColumnStatsCache::default(), + Rect::new(0, 0, 100, 12), + 0, + 0, + ) + ); + } + + #[test] + fn alignment_pane_translated_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCAT"), + ]); + alignment + .set_translation(Some(libmsa::ReadingFrame::Frame1)) + .unwrap(); + + insta::assert_snapshot!( + "alignment_pane_translated", + render_alignment_pane_text( + &alignment, + &ColumnStatsCache::default(), + Rect::new(0, 0, 100, 12), + 0, + 0, + ) + ); + } + + #[test] + fn alignment_pane_raw_diff_reference_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATGATCATCATCAT"), + raw("seq3", b"CATCATCATCATGATCAT"), + ]); + alignment.set_reference(0).unwrap(); + alignment.diff_mode = DiffMode::Reference; + + insta::assert_snapshot!( + "alignment_pane_raw_diff_reference", + render_alignment_pane_text( + &alignment, + &ColumnStatsCache::default(), + Rect::new(0, 0, 100, 12), + 0, + 0, + ) + ); + } + + #[test] + fn alignment_pane_translated_diff_reference_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATGATCATCATCAT"), + raw("seq3", b"CATCATCATCATGATCAT"), + ]); + alignment.set_reference(0).unwrap(); + alignment + .set_translation(Some(libmsa::ReadingFrame::Frame1)) + .unwrap(); + alignment.diff_mode = DiffMode::Reference; + + insta::assert_snapshot!( + "alignment_pane_translated_diff_reference", + render_alignment_pane_text( + &alignment, + &ColumnStatsCache::default(), + Rect::new(0, 0, 100, 12), + 0, + 0, + ) + ); + } + + #[test] + fn alignment_pane_raw_diff_consensus_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATGATCATCATCAT"), + raw("seq3", b"CATCATCATCATGATCAT"), + ]); + alignment.diff_mode = DiffMode::Consensus; + let metrics = metrics_with(StatsView::Raw, b"CATCATCATCATCATCAT", Some(1.0)); + + insta::assert_snapshot!( + "alignment_pane_raw_diff_consensus", + render_alignment_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 12), 0, 0,) + ); + } + + #[test] + fn alignment_pane_scrolled_with_scrollbar_snapshot() { + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCATCATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCATCATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCATCATCATCATCATCATCAT"), + ]); + + insta::assert_snapshot!( + "alignment_pane_scrolled_with_scrollbar", + render_alignment_pane_text( + &alignment, + &ColumnStatsCache::default(), + Rect::new(0, 0, 60, 12), + 0, + 10, + ) + ); + } + + #[test] + fn alignment_pane_pinned_with_vertical_scroll_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCAT"), + raw("seq4", b"CATCATCATCATCATCAT"), + raw("seq5", b"CATCATCATCATCATCAT"), + raw("seq6", b"CATCATCATCATCATCAT"), + ]); + alignment.pin(1).unwrap(); + alignment.pin(4).unwrap(); + + insta::assert_snapshot!( + "alignment_pane_pinned_with_vertical_scroll", + render_alignment_pane_text( + &alignment, + &ColumnStatsCache::default(), + Rect::new(0, 0, 100, 10), + 2, + 0, + ) + ); + } +} diff --git a/salti/src/ui/panes/consensus.rs b/salti/src/ui/panes/consensus.rs new file mode 100644 index 0000000..405d19c --- /dev/null +++ b/salti/src/ui/panes/consensus.rs @@ -0,0 +1,488 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Styled, Stylize}, + symbols::merge::MergeStrategy, + text::Line, + widgets::{Block, Paragraph, Widget}, +}; + +use crate::{ + core::{ + codon::{TranslatedByteRange, TranslationOverlay, nuc_start}, + model::AlignmentModel, + stats_cache::ColumnStatsCache, + viewport::ViewportWindow, + }, + ui::{ + rows::{ + RowRenderMode, format_row_spans, format_row_view_spans, + format_translated_byte_range_spans, format_translated_row_spans, + }, + ui_state::ThemeState, + }, +}; + +const CONSERVATION_SPARK_STRS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; + +pub(crate) struct ConsensusAlignmentPane<'a> { + pub(crate) alignment: &'a AlignmentModel, + pub(crate) window: &'a ViewportWindow, + pub(crate) metrics: &'a ColumnStatsCache, + pub(crate) theme: &'a ThemeState, +} + +impl Widget for ConsensusAlignmentPane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .border_style(self.theme.styles.border) + .style(self.theme.styles.base_block) + .merge_borders(MergeStrategy::Exact); + let inner_area = block.inner(area); + block.render(area, buf); + + let lines = + consensus_alignment_lines(self.alignment, self.window, self.metrics, self.theme); + Paragraph::new(lines) + .style(self.theme.styles.base_block) + .render(inner_area, buf); + } +} + +pub(crate) struct ConsensusSequenceIdPane<'a> { + pub(crate) alignment: &'a AlignmentModel, + pub(crate) theme: &'a ThemeState, +} + +impl Widget for ConsensusSequenceIdPane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .border_style(self.theme.styles.border) + .style(self.theme.styles.base_block) + .merge_borders(MergeStrategy::Exact); + let inner_area = block.inner(area); + block.render(area, buf); + + let lines = if shows_conservation_line(self.alignment) { + vec![ + Line::from("Reference Sequence:".set_style(self.theme.styles.accent)), + Line::from("Consensus Sequence:".set_style(self.theme.styles.accent)), + Line::from("Conservation:".set_style(self.theme.styles.accent)), + ] + } else { + vec![ + Line::from("Reference Sequence:".set_style(self.theme.styles.accent)), + Line::from("Consensus Sequence:".set_style(self.theme.styles.accent)), + ] + }; + + Paragraph::new(lines) + .style(self.theme.styles.base_block) + .render(inner_area, buf); + } +} + +fn conservation_to_spark(value: f32) -> &'static str { + let value = value.clamp(0.0, 1.0); + let max_idx = CONSERVATION_SPARK_STRS.len() - 1; + let idx = (value * max_idx as f32).round() as usize; + CONSERVATION_SPARK_STRS[idx] +} + +fn shows_conservation_line(alignment: &AlignmentModel) -> bool { + alignment.base().active_type() != libmsa::AlignmentType::Generic +} + +fn blank_line(width: usize) -> Line<'static> { + Line::raw(" ".repeat(width)) +} + +fn translated_reference_line( + alignment: &AlignmentModel, + overlay: &TranslationOverlay, + window: &ViewportWindow, + theme: &ThemeState, +) -> Line<'static> { + let Some(translated) = alignment.translated_view() else { + return Line::from("No reference selected".fg(theme.theme.text_dim).italic()); + }; + + alignment.rows().reference().map_or_else( + || Line::from("No reference selected".fg(theme.theme.text_dim).italic()), + |absolute_row| { + let Some(sequence) = translated.project_absolute_row(absolute_row) else { + return Line::from("No reference selected".fg(theme.theme.text_dim).italic()); + }; + let spans = format_translated_row_spans( + sequence, + &window.col_range, + overlay, + &theme.theme.sequence, + None, + ); + Line::from(spans) + }, + ) +} + +fn translated_consensus_line( + overlay: &TranslationOverlay, + window: &ViewportWindow, + metrics: &ColumnStatsCache, + theme: &ThemeState, +) -> Line<'static> { + let Some(protein_range) = overlay.visible_protein_range(&window.col_range) else { + return blank_line(window.col_range.len()); + }; + + let consensus_bytes: Option> = protein_range + .clone() + .map(|protein_col: usize| { + metrics + .translated_summary_at(overlay.frame, protein_col) + .map(|summary| summary.consensus.unwrap_or(b' ')) + }) + .collect(); + let Some(consensus_bytes) = consensus_bytes else { + return Line::from("Calculating consensus...".fg(theme.theme.text_dim).italic()); + }; + let spans = format_translated_byte_range_spans( + TranslatedByteRange::new(protein_range.start, &consensus_bytes), + &window.col_range, + overlay, + &theme.theme.sequence, + None, + ); + Line::from(spans) +} + +fn translated_conservation_line( + overlay: &TranslationOverlay, + window: &ViewportWindow, + metrics: &ColumnStatsCache, + theme: &ThemeState, +) -> Line<'static> { + let width = window.col_range.len(); + let mut spans = vec![ratatui::text::Span::styled(" ", theme.styles.accent_alt); width]; + + let Some(protein_range) = overlay.visible_protein_range(&window.col_range) else { + return Line::from(spans); + }; + + for protein_col in protein_range { + let Some(summary) = metrics.translated_summary_at(overlay.frame, protein_col) else { + return Line::from( + "Calculating conservation..." + .fg(theme.theme.text_dim) + .italic(), + ); + }; + let spark = summary + .conservation + .filter(|value| value.is_finite()) + .map_or(" ", conservation_to_spark); + let codon_nuc_start = nuc_start(protein_col, overlay.frame); + + for absolute_col in codon_nuc_start..=codon_nuc_start + 2 { + let Some(window_offset) = absolute_col.checked_sub(window.col_range.start) else { + continue; + }; + if window_offset >= width { + continue; + } + + spans[window_offset] = ratatui::text::Span::styled(spark, theme.styles.accent_alt); + } + } + + Line::from(spans) +} + +fn consensus_alignment_lines( + alignment: &AlignmentModel, + window: &ViewportWindow, + metrics: &ColumnStatsCache, + theme: &ThemeState, +) -> Vec> { + if let Some(overlay) = alignment.translation_overlay() { + return vec![ + translated_reference_line(alignment, &overlay, window, theme), + translated_consensus_line(&overlay, window, metrics, theme), + translated_conservation_line(&overlay, window, metrics, theme), + ]; + } + + let no_diff_mode = RowRenderMode { + alignment_type: alignment.base().active_type(), + diff_against: None, + }; + + let reference_line = alignment.rows().reference().map_or_else( + || Line::from("No reference selected".fg(theme.theme.text_dim).italic()), + |absolute_row| { + let Some(projected_row) = alignment.view().project_absolute_row(absolute_row) else { + return Line::from("No reference selected".fg(theme.theme.text_dim).italic()); + }; + let spans = format_row_view_spans( + projected_row, + &window.col_range, + &theme.theme.sequence, + no_diff_mode, + ); + Line::from(spans) + }, + ); + + let consensus_bytes: Option> = window + .col_range + .clone() + .map(|rel_col| { + metrics + .raw_summary_at(rel_col) + .map(|summary| summary.consensus.unwrap_or(b' ')) + }) + .collect(); + + let consensus_line = consensus_bytes.map_or_else( + || Line::from("Calculating consensus...".fg(theme.theme.text_dim).italic()), + |bytes| { + let spans = format_row_spans(&bytes, &theme.theme.sequence, no_diff_mode); + Line::from(spans) + }, + ); + + if shows_conservation_line(alignment) { + let conservation_line = build_conservation_line(metrics, window, theme); + vec![reference_line, consensus_line, conservation_line] + } else { + vec![reference_line, consensus_line] + } +} + +fn build_conservation_line( + metrics: &ColumnStatsCache, + window: &ViewportWindow, + theme: &ThemeState, +) -> Line<'static> { + let mut sparkline = String::with_capacity(window.col_range.len()); + + for relative_col in window.col_range.clone() { + let Some(summary) = metrics.raw_summary_at(relative_col) else { + return Line::from( + "Calculating conservation..." + .fg(theme.theme.text_dim) + .italic(), + ); + }; + let spark = summary + .conservation + .filter(|value| value.is_finite()) + .map_or(" ", conservation_to_spark); + sparkline.push_str(spark); + } + + Line::from(sparkline).set_style(theme.styles.accent_alt) +} + +#[cfg(test)] +mod tests { + use ratatui::{buffer::Buffer, layout::Rect}; + + use super::*; + use crate::{ + core::{model::StatsView, stats_cache::StatsJobResult}, + ui::layout::{AlignmentHeaderLayout, AppLayout}, + }; + + fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: id.to_string(), + sequence: sequence.to_vec(), + } + } + + fn alignment_model(sequences: Vec) -> AlignmentModel { + let alignment = libmsa::Alignment::new(sequences).unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + fn metrics_with( + view: StatsView, + consensus: &[u8], + conservation: Option, + ) -> ColumnStatsCache { + let mut cache = ColumnStatsCache::default(); + match view { + StatsView::Raw => cache.init(consensus.len()), + StatsView::Translated(frame) => { + cache.init(consensus.len() * 3); + let _ = + cache.translated_chunks_to_spawn(&(0..consensus.len()), frame, consensus.len()); + } + } + + let summaries = consensus + .iter() + .enumerate() + .map(|(position, &byte)| libmsa::ColumnSummary { + position, + consensus: Some(byte), + conservation, + }) + .collect(); + let generation = cache.generation; + let chunk_idx = 0; + let stored = cache.store(StatsJobResult { + generation, + chunk_idx, + view, + summaries: Ok(summaries), + }); + assert!(stored); + cache + } + + fn render_consensus_pane_text( + alignment: &AlignmentModel, + metrics: &ColumnStatsCache, + area: Rect, + ) -> String { + let mut buffer = Buffer::empty(area); + let layout = AppLayout::new(area, 0, AlignmentHeaderLayout::without_features()); + let window = ViewportWindow { + row_range: 0..alignment.view().row_count(), + col_range: 0..alignment.view().column_count(), + name_range: 0..0, + }; + + let theme = ThemeState::default(); + ConsensusSequenceIdPane { + alignment, + theme: &theme, + } + .render(layout.consensus_sequence_id_pane, &mut buffer); + ConsensusAlignmentPane { + alignment, + window: &window, + metrics, + theme: &theme, + } + .render(layout.consensus_alignment_pane, &mut buffer); + + buffer_text(&buffer) + } + + fn buffer_text(buffer: &Buffer) -> String { + let area = buffer.area; + let mut lines = Vec::new(); + + for y in area.top()..area.bottom() { + let mut line = String::new(); + for x in area.left()..area.right() { + let symbol = buffer[(x, y)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + while line.ends_with(' ') { + line.pop(); + } + lines.push(line); + } + + while matches!(lines.last(), Some(last) if last.is_empty()) { + lines.pop(); + } + + lines.join("\n") + } + + #[test] + fn raw_consensus_pane_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + ]); + alignment.set_reference(0).unwrap(); + let metrics = metrics_with(StatsView::Raw, b"CATCATCATCATCATCAT", Some(1.0)); + + insta::assert_snapshot!( + "consensus_pane_raw", + render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 5)) + ); + } + + #[test] + fn raw_consensus_no_reference_snapshot() { + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + ]); + let metrics = metrics_with(StatsView::Raw, b"CATCATCATCATCATCAT", Some(1.0)); + + insta::assert_snapshot!( + "consensus_pane_raw_no_reference", + render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 5)) + ); + } + + #[test] + fn translated_consensus_pane_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + ]); + alignment.set_reference(0).unwrap(); + alignment + .set_translation(Some(libmsa::ReadingFrame::Frame1)) + .unwrap(); + let metrics = metrics_with( + StatsView::Translated(libmsa::ReadingFrame::Frame1), + b"HHHHHH", + Some(1.0), + ); + + insta::assert_snapshot!( + "consensus_pane_translated", + render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 5)) + ); + } + + #[test] + fn translated_consensus_no_reference_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + ]); + alignment + .set_translation(Some(libmsa::ReadingFrame::Frame1)) + .unwrap(); + let metrics = metrics_with( + StatsView::Translated(libmsa::ReadingFrame::Frame1), + b"HHHHHH", + Some(1.0), + ); + + insta::assert_snapshot!( + "consensus_pane_translated_no_reference", + render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 5)) + ); + } + + #[test] + fn generic_consensus_hides_conservation_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"ACDEACDEACDE"), + raw("seq2", b"ACDEACDEACDE"), + ]); + alignment.set_reference(0).unwrap(); + let metrics = metrics_with(StatsView::Raw, b"ACDEACDEACDE", Some(1.0)); + + insta::assert_snapshot!( + "consensus_pane_generic_without_conservation", + render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 4)) + ); + } +} diff --git a/salti/src/ui/panes/gff.rs b/salti/src/ui/panes/gff.rs new file mode 100644 index 0000000..e5bbe7f --- /dev/null +++ b/salti/src/ui/panes/gff.rs @@ -0,0 +1,550 @@ +use std::ops::Range; + +use crossterm::event::MouseEvent; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Styled, + symbols::merge::MergeStrategy, + text::{Line, Span}, + widgets::{Block, Paragraph, Widget}, +}; + +use crate::{ + command::Command, + core::{ + gff::{Feature, Gff, Strand}, + model::AlignmentModel, + }, + input::movement::HorizontalDrag, + ui::{ + features::{DisplayFeature, display_features, feature_style}, + ui_state::ThemeState, + }, +}; + +const MIN_LABEL_WIDTH: usize = 2; +pub(crate) struct GffPane<'a> { + pub(crate) gff: &'a Gff, + pub(crate) alignment: &'a AlignmentModel, + pub(crate) viewport_col_range: &'a Range, + pub(crate) theme: &'a ThemeState, +} + +impl Widget for GffPane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .border_style(self.theme.styles.border) + .style(self.theme.styles.base_block) + .merge_borders(MergeStrategy::Exact); + let inner_area = if area.width > 2 && area.height > 2 { + block.inner(area) + } else { + Rect::default() + }; + block.render(area, buf); + + let track = + FeatureTrack::for_alignment(self.gff, self.alignment, usize::from(inner_area.width)); + let content_rows = Rect { + width: u16::try_from(track.width()).unwrap_or(u16::MAX), + ..inner_area + }; + if content_rows.width == 0 || content_rows.height == 0 || track.total_columns() == 0 { + return; + } + + let feature_rows = Rect { + height: content_rows.height.saturating_sub(1), + ..content_rows + }; + let navigation_row = Rect::new( + content_rows.x, + content_rows.y + feature_rows.height, + content_rows.width, + 1, + ); + + render_features(&track, feature_rows, self.theme, buf); + + let line = generate_scroll_line( + usize::from(navigation_row.width), + self.viewport_col_range, + track.total_columns(), + self.theme, + ); + Paragraph::new(line) + .style(self.theme.styles.base_block) + .render(navigation_row, buf); + } +} + +pub(crate) struct GffInfoPane<'a> { + pub(crate) tooltip: Option<&'a str>, + pub(crate) theme: &'a ThemeState, +} + +impl Widget for GffInfoPane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .title(Line::from( + "Feature Info".set_style(self.theme.styles.text_muted), + )) + .border_style(self.theme.styles.border) + .style(self.theme.styles.base_block) + .merge_borders(MergeStrategy::Exact); + let inner_area = if area.width > 2 && area.height > 2 { + block.inner(area) + } else { + Rect::default() + }; + block.render(area, buf); + if inner_area.width == 0 || inner_area.height == 0 { + return; + } + + let lines: Vec> = match self.tooltip { + Some(tooltip) => tooltip + .lines() + .map(|line| Line::from(line.set_style(self.theme.styles.text))) + .collect(), + None => vec![Line::from( + "Hover over a feature".set_style(self.theme.styles.text_muted), + )], + }; + + Paragraph::new(lines) + .style(self.theme.styles.base_block) + .render(inner_area, buf); + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct GffPaneState { + pan_drag: HorizontalDrag, +} + +impl GffPaneState { + pub(crate) fn handle_mouse( + &mut self, + mouse: MouseEvent, + gff_inner: Rect, + viewport_col_range: &Range, + alignment: &AlignmentModel, + ) -> Option { + let total_columns = FeatureTrack::total_columns_for_alignment(alignment); + self.pan_drag.handle_mouse( + mouse, + gff_content_area(gff_inner, total_columns), + viewport_col_range, + total_columns, + position_from_mouse, + ) + } + + pub(crate) fn is_dragging(&self) -> bool { + self.pan_drag.is_dragging() + } +} + +pub(crate) fn tooltip_at( + gff: &Gff, + alignment: &AlignmentModel, + gff_inner: Rect, + mouse_x: u16, + mouse_y: u16, +) -> Option { + let track = FeatureTrack::for_alignment(gff, alignment, usize::from(gff_inner.width)); + if gff_inner.width == 0 || gff_inner.height == 0 || track.total_columns() == 0 { + return None; + } + let content_rows = Rect { + width: u16::try_from(track.width()).unwrap_or(u16::MAX), + ..gff_inner + }; + if !content_rows.contains((mouse_x, mouse_y).into()) { + return None; + } + + let feature_rows = Rect { + height: content_rows.height.saturating_sub(1), + ..content_rows + }; + if !feature_rows.contains((mouse_x, mouse_y).into()) { + return None; + } + + let row = usize::from(mouse_y - feature_rows.y); + let x = usize::from(mouse_x - feature_rows.x); + + track.feature_at(x, row).map(format_tooltip) +} + +pub(crate) fn feature_row_count(gff: &Gff, alignment: &AlignmentModel, width: usize) -> usize { + FeatureTrack::for_alignment(gff, alignment, width).row_count() +} + +fn gff_content_area(area: Rect, total_columns: usize) -> Rect { + let width = area + .width + .min(u16::try_from(total_columns).unwrap_or(u16::MAX)); + Rect { width, ..area } +} + +fn format_tooltip(feature: &Feature) -> String { + let length = feature.range.len(); + format!( + "{} ({}) — {}\n{}-{} • {} nt", + feature.name, + feature.kind, + feature.strand, + feature.range.start + 1, + feature.range.end, + length, + ) +} + +fn render_features(track: &FeatureTrack<'_>, inner: Rect, theme: &ThemeState, buf: &mut Buffer) { + let width = usize::from(inner.width); + let max_row = usize::from(inner.height); + let blank = (' ', theme.styles.base_block); + let mut cells = vec![blank; width * max_row]; + + for placed_feature in track.placed_features() { + if placed_feature.row >= max_row { + continue; + } + + let feature = placed_feature.feature; + let feature_span = &placed_feature.span; + let styles = feature_style(theme, placed_feature.colour_idx); + let feature_style = styles.background; + let text_style = styles.text; + + for x in feature_span.start..feature_span.end { + let idx = placed_feature.row * width + x; + cells[idx] = (' ', feature_style); + } + + let available = feature_span.len(); + let (label_x, label_width, strand_arrow) = match feature.strand { + Strand::Forward if 0 < available => ( + feature_span.start, + available.saturating_sub(1), + Some((available - 1, '→')), + ), + Strand::Reverse if 0 < available => ( + feature_span.start + 1, + available.saturating_sub(1), + Some((0, '←')), + ), + Strand::Unknown | Strand::Forward | Strand::Reverse => { + (feature_span.start, available, None) + } + }; + + if let Some((arrow_offset, arrow)) = strand_arrow { + let x = feature_span.start + arrow_offset; + let idx = placed_feature.row * width + x; + cells[idx] = (arrow, text_style); + } + + if label_width >= MIN_LABEL_WIDTH { + let truncated_len = feature.name.chars().take(label_width).count(); + let label_offset = (label_width - truncated_len) / 2; + let label_start = label_x + label_offset; + for (i, ch) in feature.name.chars().take(label_width).enumerate() { + let x = label_start + i; + let idx = placed_feature.row * width + x; + cells[idx] = (ch, text_style); + } + } + } + + let lines: Vec> = cells + .chunks(width) + .map(|row| { + let mut spans = Vec::new(); + let mut text = String::new(); + let mut current_style = row[0].1; + + for &(ch, style) in row { + if style != current_style && !text.is_empty() { + spans.push(Span::styled(std::mem::take(&mut text), current_style)); + current_style = style; + } + text.push(ch); + } + + if !text.is_empty() { + spans.push(Span::styled(text, current_style)); + } + + Line::from(spans) + }) + .collect(); + Paragraph::new(lines) + .style(theme.styles.base_block) + .render(inner, buf); +} + +#[derive(Debug, Clone)] +struct FeatureTrack<'a> { + placed_features: Vec>, + total_columns: usize, + width: usize, +} + +impl<'a> FeatureTrack<'a> { + fn for_alignment(gff: &'a Gff, alignment: &AlignmentModel, available_width: usize) -> Self { + let total_columns = alignment.view().column_count(); + let width = available_width.min(total_columns); + let display_features = display_features(gff, alignment); + let placed_features = placed_features(&display_features, width, total_columns); + Self { + placed_features, + total_columns, + width, + } + } + + fn total_columns_for_alignment(alignment: &AlignmentModel) -> usize { + alignment.view().column_count() + } + + fn total_columns(&self) -> usize { + self.total_columns + } + + fn width(&self) -> usize { + self.width + } + + fn placed_features(&self) -> &[PlacedFeature<'a>] { + &self.placed_features + } + + fn row_count(&self) -> usize { + self.placed_features + .iter() + .map(|placed_feature| placed_feature.row + 1) + .max() + .unwrap_or(0) + } + + fn feature_at(&self, x: usize, row: usize) -> Option<&'a Feature> { + self.placed_features + .iter() + .find(|placed_feature| placed_feature.row == row && placed_feature.span.contains(&x)) + .map(|placed_feature| placed_feature.feature) + } +} + +#[derive(Debug, Clone)] +struct PlacedFeature<'a> { + feature: &'a Feature, + span: Range, + row: usize, + colour_idx: usize, +} + +fn placed_features<'a>( + display_features: &[DisplayFeature<'a>], + width: usize, + total_columns: usize, +) -> Vec> { + let mut res = Vec::new(); + let mut alternating_row = 0; + let mut stack_offset = 0; + + for display_feature in display_features { + let Some(span) = feature_drawn_span(display_feature, width, total_columns) else { + continue; + }; + + let same_as_previous = res + .last() + .is_some_and(|placed_feature: &PlacedFeature<'_>| placed_feature.span == span); + let row = if same_as_previous { + stack_offset += 1; + alternating_row + stack_offset + } else { + alternating_row = res.len() % 2; + stack_offset = 0; + alternating_row + }; + res.push(PlacedFeature { + feature: display_feature.feature, + span, + row, + colour_idx: display_feature.colour_idx, + }); + } + + res +} + +fn feature_drawn_span( + display_feature: &DisplayFeature<'_>, + width: usize, + total_columns: usize, +) -> Option> { + let screen_span = feature_screen_span(display_feature, width, total_columns)?; + let drawn_width = screen_span.len().saturating_sub(1).max(1); + Some(screen_span.start..screen_span.start + drawn_width) +} + +fn feature_screen_span( + display_feature: &DisplayFeature<'_>, + width: usize, + total_columns: usize, +) -> Option> { + if width == 0 || total_columns == 0 { + return None; + } + + let x_start = display_feature + .relative_col_range + .start + .saturating_mul(width) + / total_columns; + let x_end = display_feature + .relative_col_range + .end + .saturating_mul(width) + .div_ceil(total_columns) + .max(x_start + 1) + .min(width); + Some(x_start..x_end) +} + +fn scroll_bar_span( + width: usize, + viewport_col_range: &Range, + total_columns: usize, +) -> Option> { + if total_columns == 0 || width == 0 { + return None; + } + + let viewport_span = viewport_col_range + .end + .saturating_sub(viewport_col_range.start); + let thumb_width = viewport_span + .saturating_mul(width) + .div_ceil(total_columns) + .max(1) + .min(width); + let thumb_start = + (viewport_col_range.start.saturating_mul(width) / total_columns).min(width - thumb_width); + + Some(thumb_start..thumb_start + thumb_width) +} + +fn generate_scroll_line( + width: usize, + viewport_col_range: &Range, + total_columns: usize, + theme: &ThemeState, +) -> Line<'static> { + let thumb_span = scroll_bar_span(width, viewport_col_range, total_columns); + let spans: Vec> = (0..width) + .map(|x| { + if thumb_span.as_ref().is_some_and(|span| span.contains(&x)) { + Span::styled("▁", theme.styles.accent) + } else { + Span::styled(" ", theme.styles.base_block) + } + }) + .collect(); + Line::from(spans) +} + +fn position_from_mouse(mouse_x: u16, area: Rect, total_columns: usize) -> usize { + let offset = usize::from(mouse_x.saturating_sub(area.x)); + let width = usize::from(area.width); + let column = offset.saturating_mul(total_columns) / width; + column.min(total_columns.saturating_sub(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feature(start: usize, end: usize) -> Feature { + feature_with_name("gene", start, end) + } + + fn feature_with_name(name: &str, start: usize, end: usize) -> Feature { + Feature { + name: name.to_string(), + kind: crate::core::gff::FeatureType::Gene, + range: start..end + 1, + strand: Strand::Forward, + } + } + + fn raw(sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: "seq".to_string(), + sequence: sequence.to_vec(), + } + } + + fn model_with_sequence(sequence: &[u8]) -> AlignmentModel { + let alignment = libmsa::Alignment::new(vec![raw(sequence)]).unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + fn model_with_len(len: usize) -> AlignmentModel { + model_with_sequence(&vec![b'A'; len]) + } + + #[test] + fn placed_features_alternate_rows() { + let gff = Gff { + features: vec![feature(0, 1), feature(2, 3), feature(4, 5)], + }; + let model = model_with_len(6); + let display_features = display_features(&gff, &model); + let rows: Vec = placed_features(&display_features, 6, model.view().column_count()) + .into_iter() + .map(|placed_feature| placed_feature.row) + .collect(); + + assert_eq!(rows, vec![0, 1, 0]); + assert_eq!(feature_row_count(&gff, &model, 6), 2); + } + + #[test] + fn tooltip_uses_stacked_feature_rows() { + let gff = Gff { + features: vec![ + feature_with_name("gene1", 0, 0), + feature_with_name("gene2", 1, 1), + ], + }; + let model = model_with_len(100); + let rows = Rect::new(10, 5, 1, 3); + + let tooltip = tooltip_at(&gff, &model, rows, 10, 6).unwrap(); + + assert!(tooltip.starts_with("gene2 ")); + } + + #[test] + fn content_area_shrinks_to_visible_columns() { + let area = Rect::new(10, 5, 100, 3); + + assert_eq!(gff_content_area(area, 12), Rect::new(10, 5, 12, 3)); + assert_eq!(gff_content_area(area, 120), area); + } + + #[test] + fn viewport_thumb_scales_with_visible_coordinate_span() { + let nucleotide = scroll_bar_span(12, &(0..4), 12).unwrap(); + let protein = scroll_bar_span(12, &(0..4), 6).unwrap(); + + assert!(nucleotide.len() < protein.len()); + } +} diff --git a/salti/src/ui/panes/local_feature_track.rs b/salti/src/ui/panes/local_feature_track.rs new file mode 100644 index 0000000..9e3d985 --- /dev/null +++ b/salti/src/ui/panes/local_feature_track.rs @@ -0,0 +1,279 @@ +use std::ops::Range; + +use ratatui::{ + buffer::Buffer, + layout::Rect, + text::{Line, Span}, + widgets::{Paragraph, Widget}, +}; + +use crate::{ + core::{ + gff::{Feature, Gff, Strand}, + model::AlignmentModel, + viewport::ViewportWindow, + }, + ui::{ + features::{DisplayFeature, FeatureMap, display_features, feature_style}, + ui_state::ThemeState, + }, +}; + +const MAX_LOCAL_FEATURE_ROWS: usize = 5; +const MIN_LABEL_WIDTH: usize = 1; + +pub(crate) struct LocalFeatureTrack<'a> { + pub(crate) gff: &'a Gff, + pub(crate) alignment: &'a AlignmentModel, + pub(crate) window: &'a ViewportWindow, + pub(crate) theme: &'a ThemeState, +} + +impl Widget for LocalFeatureTrack<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + + let display_features = display_features(self.gff, self.alignment); + let placed_features = place_visible_features(&display_features, &self.window.col_range); + render_features(&placed_features, area, self.theme, buf); + } +} + +pub(crate) fn local_feature_row_count( + gff: &Gff, + alignment: &AlignmentModel, + col_range: &Range, +) -> usize { + let mapping = FeatureMap::for_alignment(alignment); + let mut row_ends = [0; MAX_LOCAL_FEATURE_ROWS]; + let mut row_count = 0; + + for feature in &gff.features { + let Some(relative_col_range) = mapping.map_feature(alignment.view(), feature) else { + continue; + }; + let Some(visible_range) = intersect(&relative_col_range, col_range) else { + continue; + }; + let span = visible_range.start - col_range.start..visible_range.end - col_range.start; + let Some(row) = row_ends.iter().position(|&row_end| row_end <= span.start) else { + continue; + }; + row_ends[row] = span.end; + row_count = row_count.max(row + 1); + } + + row_count.max(1) +} + +#[derive(Debug, Clone)] +struct LocalPlacedFeature<'a> { + feature: &'a Feature, + span: Range, + row: usize, + colour_idx: usize, +} + +fn place_visible_features<'a>( + display_features: &[DisplayFeature<'a>], + col_range: &Range, +) -> Vec> { + let mut res: Vec> = Vec::new(); + let mut row_ends = [0; MAX_LOCAL_FEATURE_ROWS]; + + for display_feature in display_features { + let Some(visible_range) = intersect(&display_feature.relative_col_range, col_range) else { + continue; + }; + let span = visible_range.start - col_range.start..visible_range.end - col_range.start; + let Some(row) = row_ends.iter().position(|&row_end| row_end <= span.start) else { + continue; + }; + row_ends[row] = span.end; + res.push(LocalPlacedFeature { + feature: display_feature.feature, + span, + row, + colour_idx: display_feature.colour_idx, + }); + } + + res +} + +fn intersect(left: &Range, right: &Range) -> Option> { + let start = left.start.max(right.start); + let end = left.end.min(right.end); + (start < end).then_some(start..end) +} + +fn render_features( + placed_features: &[LocalPlacedFeature<'_>], + area: Rect, + theme: &ThemeState, + buf: &mut Buffer, +) { + let width = usize::from(area.width); + let height = usize::from(area.height); + let blank = (' ', theme.styles.base_block); + let mut cells = vec![blank; width * height]; + + for placed_feature in placed_features { + if height <= placed_feature.row { + continue; + } + let styles = feature_style(theme, placed_feature.colour_idx); + let span = placed_feature.span.start.min(width)..placed_feature.span.end.min(width); + if span.is_empty() { + continue; + } + + for x in span.clone() { + cells[placed_feature.row * width + x] = (' ', styles.background); + } + + draw_label( + &mut cells, + width, + placed_feature.row, + span, + placed_feature.feature, + styles.text, + ); + } + + let lines: Vec> = cells + .chunks(width) + .map(|row| { + let mut spans = Vec::new(); + let mut text = String::new(); + let mut current_style = row[0].1; + + for &(ch, style) in row { + if style != current_style && !text.is_empty() { + spans.push(Span::styled(std::mem::take(&mut text), current_style)); + current_style = style; + } + text.push(ch); + } + + if !text.is_empty() { + spans.push(Span::styled(text, current_style)); + } + + Line::from(spans) + }) + .collect(); + Paragraph::new(lines) + .style(theme.styles.base_block) + .render(area, buf); +} + +fn draw_label( + cells: &mut [(char, ratatui::style::Style)], + width: usize, + row: usize, + span: Range, + feature: &Feature, + text_style: ratatui::style::Style, +) { + let available = span.len(); + if available == 0 { + return; + } + + let (label_start, label_width, arrow) = match feature.strand { + Strand::Forward => ( + span.start, + available.saturating_sub(1), + Some((span.end - 1, '→')), + ), + Strand::Reverse => ( + span.start + 1, + available.saturating_sub(1), + Some((span.start, '←')), + ), + Strand::Unknown => (span.start, available, None), + }; + + if let Some((arrow_x, arrow)) = arrow { + cells[row * width + arrow_x] = (arrow, text_style); + } + + if label_width < MIN_LABEL_WIDTH { + return; + } + + let truncated: String = feature.name.chars().take(label_width).collect(); + let truncated_len = truncated.chars().count(); + let label_offset = (label_width - truncated_len) / 2; + let x_start = label_start + label_offset; + for (offset, ch) in truncated.chars().enumerate() { + cells[row * width + x_start + offset] = (ch, text_style); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::gff::FeatureType; + + fn feature_with_name(name: &str, start: usize, end: usize) -> Feature { + Feature { + name: name.to_string(), + kind: FeatureType::Gene, + range: start..end, + strand: Strand::Forward, + } + } + + fn raw(sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: "seq".to_string(), + sequence: sequence.to_vec(), + } + } + + fn model_with_len(len: usize) -> AlignmentModel { + let alignment = libmsa::Alignment::new(vec![raw(&vec![b'A'; len])]).unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + #[test] + fn local_feature_row_count_is_one_when_no_feature_is_visible() { + let gff = Gff { + features: vec![feature_with_name("left", 0, 5)], + }; + let model = model_with_len(20); + + assert_eq!(local_feature_row_count(&gff, &model, &(10..15)), 1); + } + + #[test] + fn local_features_stack_overlapping_visible_spans() { + let gff = Gff { + features: vec![ + feature_with_name("a", 0, 10), + feature_with_name("b", 2, 8), + feature_with_name("c", 10, 12), + ], + }; + let model = model_with_len(20); + + assert_eq!(local_feature_row_count(&gff, &model, &(0..12)), 2); + } + + #[test] + fn local_feature_rows_are_capped_at_five() { + let gff = Gff { + features: (0..8) + .map(|idx| feature_with_name(&format!("f{idx}"), 0, 10)) + .collect(), + }; + let model = model_with_len(20); + + assert_eq!(local_feature_row_count(&gff, &model, &(0..12)), 5); + } +} diff --git a/salti/src/ui/panes/mod.rs b/salti/src/ui/panes/mod.rs new file mode 100644 index 0000000..6c228ed --- /dev/null +++ b/salti/src/ui/panes/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod alignment; +pub(crate) mod consensus; +pub(crate) mod gff; +pub(crate) mod local_feature_track; +pub(crate) mod ruler; +pub(crate) mod sequence_id; +pub(crate) mod status_bars; diff --git a/salti/src/ui/panes/ruler.rs b/salti/src/ui/panes/ruler.rs new file mode 100644 index 0000000..c1a21a8 --- /dev/null +++ b/salti/src/ui/panes/ruler.rs @@ -0,0 +1,304 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Styled, + text::{Line, Span}, + widgets::{Paragraph, Widget}, +}; + +use crate::{ + core::{model::AlignmentModel, viewport::ViewportWindow}, + ui::ui_state::ThemeState, +}; + +pub(crate) struct Ruler<'a> { + pub(crate) alignment: &'a AlignmentModel, + pub(crate) window: &'a ViewportWindow, + pub(crate) theme: &'a ThemeState, +} + +impl Widget for Ruler<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let absolute_columns: Vec = self + .window + .col_range + .clone() + .filter_map(|relative_col| self.alignment.view().absolute_column_id(relative_col)) + .collect(); + let filtered_leading = self.window.col_range.start == 0 + && self + .alignment + .view() + .absolute_column_id(0) + .is_some_and(|first| first > 0); + let filtered_trailing = self.window.col_range.end >= self.alignment.view().column_count() + && self.alignment.base().column_count() > 0 + && self + .alignment + .view() + .absolute_column_id(self.alignment.view().column_count().saturating_sub(1)) + .is_some_and(|last| last < self.alignment.base().column_count() - 1); + let (number_line, marker_line) = build_ruler( + &absolute_columns, + filtered_leading, + filtered_trailing, + self.theme, + ); + Paragraph::new(vec![number_line, marker_line]) + .style(self.theme.styles.base_block) + .render(area, buf); + } +} + +fn add_number_to_ruler( + number_line: &mut [Span<'static>], + centre_pos: usize, + number: usize, + theme: &ThemeState, +) -> bool { + let number_string = number.to_string(); + let number_length = number_string.len(); + let ruler_width = number_line.len(); + let start_idx = centre_pos + .saturating_sub(number_length / 2) + .min(ruler_width.saturating_sub(number_length)); + let left_padding = start_idx.saturating_sub(1); + let right_padding = (start_idx + number_length + 1).min(ruler_width); + + if number_line[left_padding..right_padding] + .iter() + .any(|span| span.content.as_ref() != " ") + { + return false; + } + + for (offset, digit) in number_string.chars().enumerate() { + if let Some(cell) = number_line.get_mut(start_idx + offset) { + *cell = digit.to_string().set_style(theme.styles.accent); + } + } + + true +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BreakMarker { + Leading, + Trailing, +} + +fn break_positions( + absolute_columns: &[usize], + filtered_leading: bool, + filtered_trailing: bool, +) -> Vec<(usize, BreakMarker)> { + let width = absolute_columns.len(); + if width == 0 { + return Vec::new(); + } + + let mut breaks = Vec::new(); + + if filtered_leading { + breaks.push((0, BreakMarker::Leading)); + } + + for (index, pair) in absolute_columns.windows(2).enumerate() { + if pair[1] != pair[0] + 1 { + breaks.push((index, BreakMarker::Trailing)); + } + } + + if filtered_trailing { + let last = width - 1; + if !breaks.iter().any(|&(position, _)| position == last) { + breaks.push((last, BreakMarker::Trailing)); + } + } + + breaks +} + +fn dense_break_marker_position(position: usize, marker: BreakMarker, width: usize) -> usize { + match marker { + BreakMarker::Leading => position, + BreakMarker::Trailing => { + if position + 1 < width { + position + 1 + } else { + position + } + } + } +} + +fn dense_break_spans(breaks: &[(usize, BreakMarker)], width: usize) -> Vec<(usize, usize)> { + let marker_positions: Vec = breaks + .iter() + .map(|&(position, marker)| dense_break_marker_position(position, marker, width)) + .collect(); + let mut spans = Vec::new(); + let mut cluster_start = 0; + + while cluster_start < marker_positions.len() { + let mut cluster_end = cluster_start + 1; + while cluster_end < marker_positions.len() + && marker_positions[cluster_end] <= marker_positions[cluster_end - 1] + 3 + { + cluster_end += 1; + } + + if cluster_end - cluster_start >= 2 { + spans.push(( + marker_positions[cluster_start], + marker_positions[cluster_end - 1], + )); + } + + cluster_start = cluster_end; + } + + spans +} + +fn run_start_positions(absolute_columns: &[usize]) -> Vec { + let mut starts = Vec::new(); + if absolute_columns.is_empty() { + return starts; + } + + starts.push(0); + for (index, pair) in absolute_columns.windows(2).enumerate() { + if pair[1] != pair[0] + 1 { + starts.push(index + 1); + } + } + + starts +} + +fn build_ruler( + absolute_columns: &[usize], + filtered_leading: bool, + filtered_trailing: bool, + theme: &ThemeState, +) -> (Line<'static>, Line<'static>) { + let width = absolute_columns.len(); + if width == 0 { + return (Line::from(""), Line::from("")); + } + + let mut number_line = vec![Span::raw(" "); width]; + let mut marker_line = vec![Span::raw(" "); width]; + let breaks = break_positions(absolute_columns, filtered_leading, filtered_trailing); + let fragmented_view = !breaks.is_empty(); + let mut run_starts = + fragmented_view.then(|| run_start_positions(absolute_columns).into_iter().peekable()); + + for (index, marker_span) in marker_line.iter_mut().enumerate() { + let is_run_start = run_starts.as_mut().is_some_and(|starts| { + while starts.peek().is_some_and(|&start| start < index) { + let _ = starts.next(); + } + matches!(starts.peek(), Some(&start) if start == index) + }); + let display_pos = absolute_columns[index] + 1; + if display_pos == 1 || display_pos.is_multiple_of(5) { + let is_major_tick = display_pos.is_multiple_of(10); + *marker_span = if is_major_tick { + "|".set_style(theme.styles.accent) + } else { + ".".set_style(theme.styles.text_dim) + }; + + if is_run_start { + let _ = run_starts.as_mut().and_then(Iterator::next); + } + if is_major_tick || display_pos == 1 || is_run_start { + let _ = add_number_to_ruler(&mut number_line, index, display_pos, theme); + } + } + } + + let dense_spans = dense_break_spans(&breaks, width); + + for (position, marker) in breaks { + let marker_position = dense_break_marker_position(position, marker, width); + if dense_spans + .iter() + .any(|&(start, end)| start <= marker_position && marker_position <= end) + { + continue; + } + + let symbol = match marker { + BreakMarker::Leading => "‹", + BreakMarker::Trailing => "›", + }; + marker_line[position] = symbol.set_style(theme.styles.warning); + } + + for (start, end) in dense_spans { + for marker in marker_line.iter_mut().take(end + 1).skip(start) { + *marker = "~".set_style(theme.styles.warning); + } + } + + (Line::from(number_line), Line::from(marker_line)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn basic_ruler_marks_positions() { + let theme = ThemeState::default(); + let absolute_columns: Vec = (0..18).collect(); + + let (number_line, marker_line) = build_ruler(&absolute_columns, false, false, &theme); + + assert_eq!(line_text(&number_line), "1 10 "); + assert_eq!(line_text(&marker_line), ". . | . "); + } + + #[test] + fn fragmented_ruler_marks_break() { + let theme = ThemeState::default(); + let absolute_columns = [0, 1, 2, 6, 7]; + + let (number_line, marker_line) = build_ruler(&absolute_columns, false, false, &theme); + + assert_eq!(line_text(&number_line), "1 "); + assert_eq!(line_text(&marker_line), ". › "); + } + + #[test] + fn dense_fragmented_ruler_uses_span() { + let theme = ThemeState::default(); + let absolute_columns = [0, 2, 4, 6, 8, 10]; + + let (number_line, marker_line) = build_ruler(&absolute_columns, false, false, &theme); + + assert_eq!(line_text(&number_line), "1 5 "); + assert_eq!(line_text(&marker_line), ".~~~~~"); + } + + #[test] + fn filtered_ruler_marks_edges() { + let theme = ThemeState::default(); + let absolute_columns = [2, 3, 4, 5, 6]; + + let (number_line, marker_line) = build_ruler(&absolute_columns, true, true, &theme); + + assert_eq!(line_text(&number_line), " "); + assert_eq!(line_text(&marker_line), "‹ . ›"); + } +} diff --git a/salti/src/ui/panes/sequence_id.rs b/salti/src/ui/panes/sequence_id.rs new file mode 100644 index 0000000..259b898 --- /dev/null +++ b/salti/src/ui/panes/sequence_id.rs @@ -0,0 +1,307 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Style, Styled}, + symbols::merge::MergeStrategy, + text::Line, + widgets::{Block, Paragraph, Widget}, +}; + +use crate::{ + core::{model::AlignmentModel, viewport::ViewportWindow}, + ui::{ + layout::{AlignmentHeaderLayout, pinned_section_layout}, + ui_state::ThemeState, + }, +}; + +pub(crate) struct SequenceIdPane<'a> { + pub(crate) alignment: &'a AlignmentModel, + pub(crate) window: &'a ViewportWindow, + pub(crate) header: AlignmentHeaderLayout, + pub(crate) theme: &'a ThemeState, +} + +impl Widget for SequenceIdPane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .border_style(self.theme.styles.border) + .style(self.theme.styles.base_block) + .merge_borders(MergeStrategy::Exact); + let inner_area = block.inner(area); + block.render(area, buf); + + render_sequence_id_rows( + self.alignment, + self.window, + self.header, + self.theme, + inner_area, + buf, + ); + } +} + +fn build_sequence_id_line( + theme: &ThemeState, + absolute_row: usize, + alignment_id: &str, + name_offset: usize, + name_width: usize, + id_style: Style, +) -> Line<'static> { + let number_prefix = format!("{} ", absolute_row + 1).set_style(theme.styles.success); + // sequence IDs can be longer than the visible sequence ID pane width. + let id_slice: String = alignment_id + .chars() + .skip(name_offset) + .take(name_width) + .collect(); + + Line::from(vec![number_prefix, id_slice.set_style(id_style)]) +} + +fn build_pinned_divider_line(width: usize, style: Style) -> Line<'static> { + Line::from("─".repeat(width).set_style(style)) +} + +fn render_sequence_id_rows( + alignment: &AlignmentModel, + window: &ViewportWindow, + header: AlignmentHeaderLayout, + theme: &ThemeState, + area: Rect, + buf: &mut Buffer, +) { + let local_feature_height = usize::from(header.local_feature_rows); + let ruler_height = usize::from(header.ruler_rows); + let available_content_height = area.height.saturating_sub(header.height()) as usize; + let band_layout = + pinned_section_layout(alignment.rows().pinned().len(), available_content_height); + let mut lines = Vec::with_capacity(ruler_height + area.height as usize); + + let has_pins = !alignment.rows().pinned().is_empty(); + for _ in 0..local_feature_height { + lines.push(Line::from(" ")); + } + + for ruler_row in 0..ruler_height { + if ruler_row == 1 && has_pins { + lines.push(Line::from( + "Pinned sequences:".set_style(theme.styles.text_muted), + )); + } else { + lines.push(Line::from(" ")); + } + } + + let name_width = window + .name_range + .end + .saturating_sub(window.name_range.start); + + for &absolute_row in alignment + .rows() + .pinned() + .iter() + .take(band_layout.pinned_rendered) + { + let Some(sequence) = alignment.base().project_absolute_row(absolute_row) else { + continue; + }; + lines.push(build_sequence_id_line( + theme, + absolute_row, + sequence.id(), + window.name_range.start, + name_width, + theme.styles.accent, + )); + } + + if band_layout.divider_height == 1 { + lines.push(build_pinned_divider_line( + area.width as usize, + theme.styles.border, + )); + } + + for relative_row in window.row_range.clone() { + let Some(sequence) = alignment.view().sequence(relative_row) else { + continue; + }; + lines.push(build_sequence_id_line( + theme, + sequence.absolute_row_id(), + sequence.id(), + window.name_range.start, + name_width, + theme.styles.text, + )); + } + + Paragraph::new(lines) + .alignment(ratatui::layout::HorizontalAlignment::Left) + .style(theme.styles.base_block) + .render(area, buf); +} + +#[cfg(test)] +mod tests { + use ratatui::{buffer::Buffer, layout::Rect}; + + use super::*; + use crate::ui::layout::AppLayout; + + fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: id.to_string(), + sequence: sequence.to_vec(), + } + } + + fn alignment_model(sequences: Vec) -> AlignmentModel { + let alignment = libmsa::Alignment::new(sequences).unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + fn render_sequence_id_pane_text(alignment: &AlignmentModel, window: &ViewportWindow) -> String { + render_sequence_id_pane_text_with_header( + alignment, + window, + AlignmentHeaderLayout::without_features(), + ) + } + + fn render_sequence_id_pane_text_with_header( + alignment: &AlignmentModel, + window: &ViewportWindow, + header: AlignmentHeaderLayout, + ) -> String { + let area = Rect::new(0, 0, 150, 12); + let mut buffer = Buffer::empty(area); + let layout = AppLayout::new(area, 0, header); + let theme = ThemeState::default(); + + SequenceIdPane { + alignment, + window, + header: layout.alignment_header, + theme: &theme, + } + .render(layout.sequence_id_pane, &mut buffer); + + buffer_text(&buffer) + } + + fn buffer_text(buffer: &Buffer) -> String { + let area = buffer.area; + let mut lines = Vec::new(); + + for y in area.top()..area.bottom() { + let mut line = String::new(); + for x in area.left()..area.right() { + let symbol = buffer[(x, y)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + while line.ends_with(' ') { + line.pop(); + } + lines.push(line); + } + + while matches!(lines.last(), Some(last) if last.is_empty()) { + lines.pop(); + } + + lines.join("\n") + } + + #[test] + fn basic_sequence_ids_snapshot() { + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCAT"), + ]); + let window = ViewportWindow { + row_range: 0..alignment.view().row_count(), + col_range: 0..alignment.view().column_count(), + name_range: 0..18, + }; + + insta::assert_snapshot!( + "sequence_id_pane_basic", + render_sequence_id_pane_text(&alignment, &window) + ); + } + + #[test] + fn local_feature_rows_reserved_snapshot() { + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCAT"), + ]); + let window = ViewportWindow { + row_range: 0..alignment.view().row_count(), + col_range: 0..alignment.view().column_count(), + name_range: 0..18, + }; + + insta::assert_snapshot!( + "sequence_id_pane_local_feature_rows", + render_sequence_id_pane_text_with_header( + &alignment, + &window, + AlignmentHeaderLayout::with_features(2), + ) + ); + } + + #[test] + fn pinned_sequence_ids_snapshot() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCAT"), + raw("seq4", b"CATCATCATCATCATCAT"), + ]); + alignment.pin(1).unwrap(); + alignment.pin(3).unwrap(); + + let window = ViewportWindow { + row_range: 0..alignment.view().row_count(), + col_range: 0..alignment.view().column_count(), + name_range: 0..18, + }; + + insta::assert_snapshot!( + "sequence_id_pane_pinned", + render_sequence_id_pane_text(&alignment, &window) + ); + } + + #[test] + fn scrolled_sequence_names_snapshot() { + let alignment = alignment_model(vec![ + raw("seq1-loooooooooooong-name", b"CATCATCATCATCATCAT"), + raw("seq2-loooooooooooong-name", b"CATCATCATCATCATCAT"), + raw("seq3-loooooooooooong-name", b"CATCATCATCATCATCAT"), + ]); + let window = ViewportWindow { + row_range: 0..alignment.view().row_count(), + col_range: 0..alignment.view().column_count(), + name_range: 5..23, + }; + + insta::assert_snapshot!( + "sequence_id_pane_name_scroll", + render_sequence_id_pane_text(&alignment, &window) + ); + } +} diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_basic.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_basic.snap new file mode 100644 index 0000000..6ffb49b --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_basic.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/alignment.rs +expression: "render_alignment_pane_text(&alignment, &ColumnStatsCache::default(),\nRect::new(0, 0, 100, 12), 0, 0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│1 10 │ +│. . | . │ +│CATCATCATCATCATCAT │ +│CATCATCATCATCATCAT │ +│CATCATCATCATCATCAT │ +│ │ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_blank_local_feature_track.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_blank_local_feature_track.snap new file mode 100644 index 0000000..e87aca7 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_blank_local_feature_track.snap @@ -0,0 +1,13 @@ +--- +source: salti/src/ui/panes/alignment.rs +assertion_line: 520 +expression: "render_alignment_pane_text_with_gff(&alignment, &ColumnStatsCache::default(),\nSome(&gff), Rect::new(0, 0, 100, 12), 0, 0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│ │ +│1 10 │ +│. . | . │ +│CATCATCATCATCATCAT │ +│CATCATCATCATCATCAT │ +│CATCATCATCATCATCAT │ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_pinned_and_fragmented.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_pinned_and_fragmented.snap new file mode 100644 index 0000000..58d4084 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_pinned_and_fragmented.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/alignment.rs +expression: "render_alignment_pane_text(&alignment, &ColumnStatsCache::default(),\nRect::new(0, 0, 100, 12), 0, 0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│1 10 │ +│. › | . │ +│CATCATCATCATCAT │ +│CATCATCATCATCAT │ +│───────────────────────────────────────────────────────────────────────────────│ +│CATCATCATCATCAT │ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_pinned_with_vertical_scroll.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_pinned_with_vertical_scroll.snap new file mode 100644 index 0000000..5d049c8 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_pinned_with_vertical_scroll.snap @@ -0,0 +1,10 @@ +--- +source: salti/src/ui/panes/alignment.rs +expression: "render_alignment_pane_text(&alignment, &ColumnStatsCache::default(),\nRect::new(0, 0, 100, 10), 2, 0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│1 10 │ +│. . | . │ +│CATCATCATCATCATCAT │ +│───────────────────────────────────────────────────────────────────────────────│ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_raw_diff_consensus.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_raw_diff_consensus.snap new file mode 100644 index 0000000..5ed7cb2 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_raw_diff_consensus.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/alignment.rs +expression: "render_alignment_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 12), 0,\n0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│1 10 │ +│. . | . │ +│.................. │ +│......G........... │ +│............G..... │ +│ │ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_raw_diff_reference.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_raw_diff_reference.snap new file mode 100644 index 0000000..0ccf7d0 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_raw_diff_reference.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/alignment.rs +expression: "render_alignment_pane_text(&alignment, &ColumnStatsCache::default(),\nRect::new(0, 0, 100, 12), 0, 0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│1 10 │ +│. . | . │ +│......G........... │ +│............G..... │ +│ │ +│ │ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_scrolled_with_scrollbar.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_scrolled_with_scrollbar.snap new file mode 100644 index 0000000..dabf787 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_scrolled_with_scrollbar.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/alignment.rs +expression: "render_alignment_pane_text(&alignment, &ColumnStatsCache::default(),\nRect::new(0, 0, 60, 12), 0, 10,)" +--- +┌───────────────────────────────────────────────┐ +│ 20 30 │ +│ . | . | . │ +│ATCATCATCATCATCATCATCATCAT │ +│ATCATCATCATCATCATCATCATCAT │ +│ATCATCATCATCATCATCATCATCAT │ +│ │ +└───────────▬▬▬─────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_translated.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_translated.snap new file mode 100644 index 0000000..919eec7 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_translated.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/alignment.rs +expression: "render_alignment_pane_text(&alignment, &ColumnStatsCache::default(),\nRect::new(0, 0, 100, 12), 0, 0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│1 10 │ +│. . | . │ +│ H H H H H H │ +│ H H H H H H │ +│ H H H H H H │ +│ │ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_translated_diff_reference.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_translated_diff_reference.snap new file mode 100644 index 0000000..86379d5 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_translated_diff_reference.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/alignment.rs +expression: "render_alignment_pane_text(&alignment, &ColumnStatsCache::default(),\nRect::new(0, 0, 100, 12), 0, 0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│1 10 │ +│. . | . │ +│ . . D . . . │ +│ . . . . D . │ +│ │ +│ │ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_with_local_feature_track.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_with_local_feature_track.snap new file mode 100644 index 0000000..89ff89e --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__alignment__tests__alignment_pane_with_local_feature_track.snap @@ -0,0 +1,13 @@ +--- +source: salti/src/ui/panes/alignment.rs +assertion_line: 520 +expression: "render_alignment_pane_text_with_gff(&alignment, &ColumnStatsCache::default(),\nSome(&gff), Rect::new(0, 0, 100, 12), 0, 0,)" +--- +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Spike → │ +│1 10 │ +│. . | . │ +│CATCATCATCATCATCAT │ +│CATCATCATCATCATCAT │ +│CATCATCATCATCATCAT │ +└───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_generic_without_conservation.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_generic_without_conservation.snap new file mode 100644 index 0000000..402865a --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_generic_without_conservation.snap @@ -0,0 +1,9 @@ +--- +source: salti/src/ui/consensus_pane.rs +assertion_line: 510 +expression: "render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 4))" +--- +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│Reference Sequence│ACDEACDEACDE │ +│Consensus Sequence│ACDEACDEACDE │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_raw.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_raw.snap new file mode 100644 index 0000000..39187a2 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_raw.snap @@ -0,0 +1,10 @@ +--- +source: salti/src/ui/consensus_pane.rs +assertion_line: 422 +expression: "render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 5))" +--- +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│Reference Sequence│CATCATCATCATCATCAT │ +│Consensus Sequence│CATCATCATCATCATCAT │ +│Conservation: │██████████████████ │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_raw_no_reference.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_raw_no_reference.snap new file mode 100644 index 0000000..275c39b --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_raw_no_reference.snap @@ -0,0 +1,10 @@ +--- +source: salti/src/ui/consensus_pane.rs +assertion_line: 436 +expression: "render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 5))" +--- +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│Reference Sequence│No reference selected │ +│Consensus Sequence│CATCATCATCATCATCAT │ +│Conservation: │██████████████████ │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_translated.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_translated.snap new file mode 100644 index 0000000..8c8735a --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_translated.snap @@ -0,0 +1,10 @@ +--- +source: salti/src/ui/consensus_pane.rs +assertion_line: 466 +expression: "render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 5))" +--- +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│Reference Sequence│ H H H H H H │ +│Consensus Sequence│ H H H H H H │ +│Conservation: │██████████████████ │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_translated_no_reference.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_translated_no_reference.snap new file mode 100644 index 0000000..f6b8bc9 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__consensus__tests__consensus_pane_translated_no_reference.snap @@ -0,0 +1,10 @@ +--- +source: salti/src/ui/consensus_pane.rs +assertion_line: 487 +expression: "render_consensus_pane_text(&alignment, &metrics, Rect::new(0, 0, 100, 5))" +--- +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│Reference Sequence│No reference selected │ +│Consensus Sequence│ H H H H H H │ +│Conservation: │██████████████████ │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_basic.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_basic.snap new file mode 100644 index 0000000..dc740b2 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_basic.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/sequence_id.rs +expression: "render_sequence_id_pane_text(&alignment, &window)" +--- +┌────────────────────────────┐ +│ │ +│ │ +│1 seq1 │ +│2 seq2 │ +│3 seq3 │ +│ │ +└────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_local_feature_rows.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_local_feature_rows.snap new file mode 100644 index 0000000..ac31e57 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_local_feature_rows.snap @@ -0,0 +1,13 @@ +--- +source: salti/src/ui/panes/sequence_id.rs +assertion_line: 253 +expression: "render_sequence_id_pane_text_with_header(&alignment, &window,\nAlignmentHeaderLayout::with_local_features(2),)" +--- +┌────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│1 seq1 │ +│2 seq2 │ +└────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_name_scroll.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_name_scroll.snap new file mode 100644 index 0000000..5fd1ef2 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_name_scroll.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/sequence_id.rs +expression: "render_sequence_id_pane_text(&alignment, &window)" +--- +┌────────────────────────────┐ +│ │ +│ │ +│1 loooooooooooong-na │ +│2 loooooooooooong-na │ +│3 loooooooooooong-na │ +│ │ +└────────────────────────────┘ diff --git a/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_pinned.snap b/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_pinned.snap new file mode 100644 index 0000000..3417745 --- /dev/null +++ b/salti/src/ui/panes/snapshots/salti__ui__panes__sequence_id__tests__sequence_id_pane_pinned.snap @@ -0,0 +1,12 @@ +--- +source: salti/src/ui/panes/sequence_id.rs +expression: "render_sequence_id_pane_text(&alignment, &window)" +--- +┌────────────────────────────┐ +│ │ +│Pinned sequences: │ +│2 seq2 │ +│4 seq4 │ +│────────────────────────────│ +│1 seq1 │ +└────────────────────────────┘ diff --git a/salti/src/ui/panes/status_bars.rs b/salti/src/ui/panes/status_bars.rs new file mode 100644 index 0000000..1f0ab08 --- /dev/null +++ b/salti/src/ui/panes/status_bars.rs @@ -0,0 +1,415 @@ +use std::fmt::Write as _; + +use ratatui::{ + Frame, + layout::Rect, + style::Styled, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::{ + core::model::AlignmentModel, + ui::{ + selection::selection_row_bounds, + ui_state::{LoadingState, UiState}, + utils::truncate_label, + }, +}; + +/// maximum displayed character count for a selected sequence name in the status bar before truncation +const STATUS_BAR_SELECTED_NAME_MAX_CHARS: usize = 25; + +fn format_percent(fraction: f32) -> String { + let mut text = format!("{:.2}", fraction * 100.0); + while text.ends_with('0') { + text.pop(); + } + if text.ends_with('.') { + text.pop(); + } + text +} + +fn build_bottom_status_bar(alignment: Option<&AlignmentModel>, ui: &UiState) -> Vec> { + let theme = &ui.theme.styles; + let mut parts = Vec::new(); + + if let Some(alignment) = alignment { + if alignment.filter().is_active() { + let visible_rows = alignment.view().row_count(); + let mut filter_text = String::from("Filters:"); + let mut counts = format!(" ({visible_rows} rows)"); + if let Some(pattern) = alignment.filter().pattern() { + let _ = write!(filter_text, " [rows: {pattern}]"); + } + if let Some(max_gap_fraction) = alignment.filter().max_gap_fraction() { + let _ = write!( + filter_text, + " [gaps: <= {}%]", + format_percent(max_gap_fraction) + ); + } + if let Some(min_constant_fraction) = alignment.filter().min_constant_fraction() { + let _ = write!( + filter_text, + " [constant: >= {}%]", + format_percent(min_constant_fraction) + ); + } + if alignment.filter().has_column_filter() { + let visible_cols = alignment.view().column_count(); + let _ = write!(counts, " ({visible_cols} cols)"); + } + parts.push(format!("{filter_text}{counts}").set_style(theme.warning)); + } + + let translation_active = + alignment.translation().is_some() || alignment.is_reloaded_as_protein(); + if translation_active { + if !parts.is_empty() { + parts.push(Span::raw(" | ")); + } + parts.push( + format!("Translation frame: {}", alignment.translation_frame()) + .set_style(theme.text), + ); + } + } + + // optional selection info building + if let Some(selection) = ui.selection { + let (row_min, row_max) = selection_row_bounds(selection); + let selected_sequence_count = row_max - row_min + 1; + let col_start = selection.column.min(selection.end_column) + 1; + let col_end = selection.column.max(selection.end_column) + 1; + + if !parts.is_empty() { + parts.push(Span::raw(" | ")); + } + + if selected_sequence_count == 1 { + let position_label = alignment + .and_then(|alignment| alignment.translation_overlay()) + .and_then(|overlay| { + let start_col = selection.column.min(selection.end_column); + let end_col = selection.column.max(selection.end_column); + match (overlay.codon_span(start_col), overlay.codon_span(end_col)) { + (Some(start_span), Some(end_span)) => { + let start = (start_span.start - overlay.frame.offset()) / 3 + 1; + let end = (end_span.start - overlay.frame.offset()) / 3 + 1; + if start == end { + Some(start.to_string()) + } else { + Some(format!("{start}-{end}")) + } + } + _ => None, + } + }) + .unwrap_or_else(|| { + if col_start == col_end { + col_start.to_string() + } else { + format!("{col_start}-{col_end}") + } + }); + let sequence_name = if let Some(alignment) = alignment { + if let Some(sequence) = alignment.base().project_absolute_row(selection.sequence_id) + { + truncate_label(sequence.id(), STATUS_BAR_SELECTED_NAME_MAX_CHARS) + } else { + "Unknown".to_string() + } + } else { + "Unknown".to_string() + }; + parts.push( + format!("Selected: {sequence_name} @ {position_label}").set_style(theme.text), + ); + } else { + parts.push( + format!("{selected_sequence_count} sequence(s) selected @ {col_start}-{col_end}") + .set_style(theme.text), + ); + } + } + + parts +} + +fn build_top_status_bar(alignment: Option<&AlignmentModel>, ui: &UiState) -> Vec> { + let theme = &ui.theme.styles; + let file_name = ui + .meta + .input_path + .as_deref() + .map(|input| { + // for local paths, show just the file name for URLs makes more sense to show the full input. + std::path::Path::new(input) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(input) + }) + .unwrap_or("Unknown"); + + let loading_status = match &ui.meta.loading_state { + LoadingState::Idle => Span::styled("Status: Idle", theme.text_dim), + LoadingState::Loading => Span::styled("Status: Loading", theme.text_dim), + LoadingState::Loaded => Span::styled("Status: Loaded", theme.success), + LoadingState::Failed(_) => Span::styled("Status: Failed", theme.error), + }; + + let alignment_count = alignment + .map(|alignment| alignment.view().row_count()) + .unwrap_or(0); + let alignment_length = alignment + .map(|alignment| alignment.base().column_count()) + .unwrap_or(0); + let position_range = alignment.map_or_else( + || "Positions: 0-0".to_string(), + |alignment| { + let window = ui.viewport.window(); + match ( + alignment.view().absolute_column_id(window.col_range.start), + window + .col_range + .end + .checked_sub(1) + .and_then(|end| alignment.view().absolute_column_id(end)), + ) { + (Some(start), Some(end)) => format!("Positions: {}-{}", start + 1, end + 1), + _ => "Positions: 0-0".to_string(), + } + }, + ); + + vec![ + format!("File: {file_name}").set_style(theme.text_dim), + Span::raw(" | "), + loading_status, + Span::raw(" | "), + format!("{alignment_count} alignments").set_style(theme.text), + Span::raw(" | "), + format!("Length: {alignment_length}").set_style(theme.text), + Span::raw(" | "), + position_range.set_style(theme.text), + ] +} + +pub fn render_frame( + f: &mut Frame, + top_status_area: Rect, + bottom_status_area: Rect, + alignment: Option<&AlignmentModel>, + ui: &UiState, +) { + let theme = &ui.theme.styles; + let top_status_bar = build_top_status_bar(alignment, ui); + let bottom_status_bar = build_bottom_status_bar(alignment, ui); + + if top_status_area.height > 0 { + let top_line = Line::from(top_status_bar).right_aligned(); + f.render_widget( + Paragraph::new(top_line).style(theme.panel_block), + top_status_area, + ); + } + + if bottom_status_area.height > 0 { + let contextual_line = Line::from(bottom_status_bar).right_aligned(); + f.render_widget( + Paragraph::new(contextual_line).style(theme.panel_block), + bottom_status_area, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{cli::StartupState, ui::ui_state::MouseSelection}; + + fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: id.to_string(), + sequence: sequence.to_vec(), + } + } + + fn status_text(spans: &[Span<'_>]) -> String { + spans.iter().map(|span| span.content.as_ref()).collect() + } + + fn alignment_model(sequences: Vec) -> AlignmentModel { + let alignment = libmsa::Alignment::new(sequences).unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + fn ui_state() -> UiState { + let mut ui = UiState::new(StartupState::default()); + ui.meta.loading_state = LoadingState::Loaded; + ui + } + + #[test] + fn top_status_loaded_alignment() { + let alignment = alignment_model(vec![ + raw("seq1", b"ACGTACGT"), + raw("seq2", b"ACGTAC-T"), + raw("seq3", b"ACGTACGA"), + ]); + let mut ui = ui_state(); + ui.meta.input_path = Some("/iamnotreal.fasta".to_string()); + ui.viewport.update_dimensions(5, 3, 0); + ui.viewport.set_bounds( + alignment.view().row_count(), + alignment.view().column_count(), + alignment.base().max_id_len(), + ); + + assert_eq!( + status_text(&build_top_status_bar(Some(&alignment), &ui)), + "File: iamnotreal.fasta | Status: Loaded | 3 alignments | Length: 8 | Positions: 1-5" + ); + } + + #[test] + fn top_status_failed_load() { + let mut ui = UiState::new(StartupState::default()); + ui.meta.loading_state = LoadingState::Failed("boom".to_string()); + + assert_eq!( + status_text(&build_top_status_bar(None, &ui)), + "File: Unknown | Status: Failed | 0 alignments | Length: 0 | Positions: 0-0" + ); + } + + #[test] + fn bottom_status_filters_translation() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"ATGAAATTT"), + raw("seq2", b"ATG---TTT"), + raw("seq3", b"ATGAAGTTT"), + ]); + alignment.set_filter("seq1|seq2".to_string()).unwrap(); + alignment.set_gap_filter(Some(0.5)).unwrap(); + alignment + .set_translation(Some(libmsa::ReadingFrame::Frame2)) + .unwrap(); + + assert_eq!( + status_text(&build_bottom_status_bar(Some(&alignment), &ui_state())), + "Filters: [rows: seq1|seq2] [gaps: <= 50%] (2 rows) (9 cols) | Translation frame: 2" + ); + } + + #[test] + fn bottom_status_constant_filter() { + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCAT"), + raw("seq2", b"CATCATCATCAT"), + raw("seq3", b"CATCATCATCAT"), + ]); + alignment.set_constant_filter(Some(1.0)).unwrap(); + + assert_eq!( + status_text(&build_bottom_status_bar(Some(&alignment), &ui_state())), + "Filters: [constant: >= 100%] (3 rows) (0 cols)" + ); + } + + #[test] + fn bottom_status_single_selection() { + let alignment = alignment_model(vec![raw("seq1", b"ACGTACGT"), raw("seq2", b"ACGTACGT")]); + let mut ui = ui_state(); + ui.selection = Some(MouseSelection { + sequence_id: 0, + column: 5, + end_sequence_id: 0, + end_column: 5, + }); + + assert_eq!( + status_text(&build_bottom_status_bar(Some(&alignment), &ui)), + "Selected: seq1 @ 6" + ); + } + + #[test] + fn bottom_status_single_range_selection() { + let alignment = alignment_model(vec![raw("seq1", b"ACGTACGT"), raw("seq2", b"ACGTACGT")]); + let mut ui = ui_state(); + ui.selection = Some(MouseSelection { + sequence_id: 0, + column: 2, + end_sequence_id: 0, + end_column: 8, + }); + + assert_eq!( + status_text(&build_bottom_status_bar(Some(&alignment), &ui)), + "Selected: seq1 @ 3-9" + ); + } + + #[test] + fn bottom_status_translated_codon_selection() { + let mut alignment = + alignment_model(vec![raw("seq1", b"ATGAAATTT"), raw("seq2", b"ATGAAATTT")]); + alignment + .set_translation_frame(libmsa::ReadingFrame::Frame1) + .unwrap(); + alignment.toggle_translation_view().unwrap(); + let mut ui = ui_state(); + ui.selection = Some(MouseSelection { + sequence_id: 0, + column: 0, + end_sequence_id: 0, + end_column: 2, + }); + + assert_eq!( + status_text(&build_bottom_status_bar(Some(&alignment), &ui)), + "Translation frame: 1 | Selected: seq1 @ 1" + ); + } + + #[test] + fn bottom_status_translated_range_selection() { + let mut alignment = + alignment_model(vec![raw("seq1", b"ATGAAATTT"), raw("seq2", b"ATGAAATTT")]); + alignment + .set_translation_frame(libmsa::ReadingFrame::Frame1) + .unwrap(); + alignment.toggle_translation_view().unwrap(); + let mut ui = ui_state(); + ui.selection = Some(MouseSelection { + sequence_id: 0, + column: 0, + end_sequence_id: 0, + end_column: 8, + }); + + assert_eq!( + status_text(&build_bottom_status_bar(Some(&alignment), &ui)), + "Translation frame: 1 | Selected: seq1 @ 1-3" + ); + } + + #[test] + fn bottom_status_multi_selection() { + let mut ui = ui_state(); + ui.selection = Some(MouseSelection { + sequence_id: 0, + column: 1, + end_sequence_id: 2, + end_column: 4, + }); + + assert_eq!( + status_text(&build_bottom_status_bar(None, &ui)), + "3 sequence(s) selected @ 2-5" + ); + } +} diff --git a/salti/src/ui/render.rs b/salti/src/ui/render.rs index 45aabd3..50287a9 100644 --- a/salti/src/ui/render.rs +++ b/salti/src/ui/render.rs @@ -1,208 +1,27 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Styled, Stylize}, + text::Line, + widgets::Paragraph, +}; + use crate::{ - core::{model::AlignmentModel, stats_cache::ColumnStatsCache}, - overlay::render::render_overlays, + core::{gff::Gff, model::AlignmentModel, stats_cache::ColumnStatsCache}, ui::{ - alignment_pane::render_alignment_pane, - consensus_pane::render_consensus_pane, - frame::render_frame, - layout::{AppLayout, FrameLayout, RULER_HEIGHT_ROWS, pinned_section_layout}, - selection::{selection_row_bounds, selection_visible_col_range}, - sequence_id_pane::render_sequence_id_pane, + layers::render::render_overlays, + layout::{AppLayout, FrameLayout}, + panes::{ + alignment::AlignmentPane, + consensus::{ConsensusAlignmentPane, ConsensusSequenceIdPane}, + gff::{GffInfoPane, GffPane}, + sequence_id::SequenceIdPane, + status_bars::render_frame, + }, + selection::render_mouse_selection, ui_state::{LoadingState, UiState}, }, }; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::Color::Rgb; -use ratatui::style::{Styled, Stylize}; -use ratatui::text::Line; -use ratatui::widgets::{Block, Paragraph}; - -const SELECTION_ROW_HIGHLIGHT_ALPHA: f32 = 0.3; -const SELECTION_ROW_TINT_ALPHA: f32 = 0.22; -const SELECTION_COL_HIGHLIGHT_ALPHA: f32 = 0.28; - -fn interpolate(from: u8, to: u8, alpha: f32) -> u8 { - let from = f32::from(from); - let to = f32::from(to); - (from + (to - from) * alpha).round().clamp(0.0, 255.0) as u8 -} - -fn blend_background( - base: ratatui::style::Color, - tint: ratatui::style::Color, - alpha: f32, -) -> ratatui::style::Color { - match (base, tint) { - (Rgb(red, green, blue), Rgb(red_tint, green_tint, blue_tint)) => Rgb( - interpolate(red, red_tint, alpha), - interpolate(green, green_tint, alpha), - interpolate(blue, blue_tint, alpha), - ), - _ => tint, - } -} - -fn shader( - f: &mut Frame, - clip_area: Rect, - tint_area: Rect, - tint: ratatui::style::Color, - alpha: f32, -) { - if alpha <= 0.0 || clip_area.width == 0 || clip_area.height == 0 { - return; - } - - let x_start = tint_area.x.max(clip_area.x); - let x_end = tint_area - .x - .saturating_add(tint_area.width) - .min(clip_area.x.saturating_add(clip_area.width)); - let y_start = tint_area.y.max(clip_area.y); - let y_end = tint_area - .y - .saturating_add(tint_area.height) - .min(clip_area.y.saturating_add(clip_area.height)); - if x_start >= x_end || y_start >= y_end { - return; - } - - let buffer = f.buffer_mut(); - for y in y_start..y_end { - for x in x_start..x_end { - if let Some(cell) = buffer.cell_mut((x, y)) { - cell.set_bg(blend_background(cell.bg, tint, alpha)); - } - } - } -} - -fn render_mouse_selection( - f: &mut Frame, - layout: &AppLayout, - alignment: &AlignmentModel, - ui: &UiState, - viewport: &crate::core::Viewport, -) { - let Some(selection) = ui.selection else { - return; - }; - - let window = viewport.window(); - let id_inner_area = Block::bordered().inner(layout.sequence_id_pane); - let sequence_rows_area = layout.alignment_pane_sequence_rows; - let id_content_y = id_inner_area.y + RULER_HEIGHT_ROWS; - let id_end_x = id_inner_area.x.saturating_add(id_inner_area.width); - let sequence_end_x = sequence_rows_area - .x - .saturating_add(sequence_rows_area.width); - let band_layout = pinned_section_layout( - alignment.rows().pinned().len(), - sequence_rows_area.height as usize, - ); - let (row_min, row_max) = selection_row_bounds(selection); - - for (row_offset, &absolute_row) in alignment - .rows() - .pinned() - .iter() - .take(band_layout.pinned_rendered) - .enumerate() - { - if !(row_min..=row_max).contains(&absolute_row) { - continue; - } - - let row_y = sequence_rows_area.y + row_offset as u16; - shader( - f, - id_inner_area, - Rect::new( - id_inner_area.x, - id_content_y + row_offset as u16, - id_end_x.saturating_sub(id_inner_area.x), - 1, - ), - ui.theme.theme.accent, - SELECTION_ROW_HIGHLIGHT_ALPHA, - ); - shader( - f, - sequence_rows_area, - Rect::new( - sequence_rows_area.x, - row_y, - sequence_end_x.saturating_sub(sequence_rows_area.x), - 1, - ), - ui.theme.theme.surface_bg, - SELECTION_ROW_TINT_ALPHA, - ); - } - - let scroll_start_y = sequence_rows_area.y - + band_layout.pinned_rendered as u16 - + band_layout.divider_height as u16; - for (row_offset, relative_row) in window.row_range.clone().enumerate() { - let Some(absolute_row) = alignment.view().absolute_row_id(relative_row) else { - continue; - }; - if !(row_min..=row_max).contains(&absolute_row) { - continue; - } - - let row_y = scroll_start_y + row_offset as u16; - shader( - f, - id_inner_area, - Rect::new( - id_inner_area.x, - id_content_y - + band_layout.pinned_rendered as u16 - + band_layout.divider_height as u16 - + row_offset as u16, - id_end_x.saturating_sub(id_inner_area.x), - 1, - ), - ui.theme.theme.accent, - SELECTION_ROW_HIGHLIGHT_ALPHA, - ); - shader( - f, - sequence_rows_area, - Rect::new( - sequence_rows_area.x, - row_y, - sequence_end_x.saturating_sub(sequence_rows_area.x), - 1, - ), - ui.theme.theme.surface_bg, - SELECTION_ROW_TINT_ALPHA, - ); - } - - if let Some(visible_col_range) = - selection_visible_col_range(selection, alignment, &window.col_range) - { - let start_x = - sequence_rows_area.x + (visible_col_range.start - window.col_range.start) as u16; - let end_x_exclusive = - sequence_rows_area.x + (visible_col_range.end - window.col_range.start) as u16; - shader( - f, - sequence_rows_area, - Rect::new( - start_x, - sequence_rows_area.y, - end_x_exclusive.saturating_sub(start_x), - sequence_rows_area.height, - ), - ui.theme.theme.panel_bg, - SELECTION_COL_HIGHLIGHT_ALPHA, - ); - } -} fn render_empty_state_with_ui(f: &mut Frame, area: Rect, ui: &UiState) { let theme = &ui.theme; @@ -261,6 +80,7 @@ fn render_empty_state_with_ui(f: &mut Frame, area: Rect, ui: &UiState) { pub fn render( f: &mut Frame, alignment: Option<&AlignmentModel>, + gff: Option<&Gff>, ui: &UiState, stats_cache: &ColumnStatsCache, frame_layout: &FrameLayout, @@ -289,11 +109,64 @@ pub fn render( }; let window = ui.viewport.window(); - render_sequence_id_pane(f, layout, alignment, &window, &ui.theme); - render_alignment_pane(f, layout, alignment, &ui.viewport, stats_cache, &ui.theme); + if let Some(gff) = gff { + f.render_widget( + GffInfoPane { + tooltip: ui.gff_tooltip.as_deref(), + theme: &ui.theme, + }, + layout.gff_info_pane, + ); + f.render_widget( + GffPane { + gff, + alignment, + viewport_col_range: &window.col_range, + theme: &ui.theme, + }, + layout.gff_pane, + ); + } + + f.render_widget( + SequenceIdPane { + alignment, + window: &window, + header: layout.alignment_header, + theme: &ui.theme, + }, + layout.sequence_id_pane, + ); - render_consensus_pane(f, layout, alignment, &window, stats_cache, &ui.theme); + f.render_widget( + AlignmentPane { + alignment, + viewport: &ui.viewport, + metrics: stats_cache, + gff, + header: layout.alignment_header, + theme: &ui.theme, + }, + layout.alignment_pane, + ); + + f.render_widget( + ConsensusSequenceIdPane { + alignment, + theme: &ui.theme, + }, + layout.consensus_sequence_id_pane, + ); + f.render_widget( + ConsensusAlignmentPane { + alignment, + window: &window, + metrics: stats_cache, + theme: &ui.theme, + }, + layout.consensus_alignment_pane, + ); render_mouse_selection(f, layout, alignment, ui, &ui.viewport); render_overlays( @@ -304,3 +177,295 @@ pub fn render( ui, ); } + +#[cfg(test)] +mod tests { + use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; + + use super::*; + use crate::{ + cli::StartupState, + core::{ + model::{DiffMode, StatsView}, + stats_cache::StatsJobResult, + }, + ui::{ + layers::{ + notification::{Notification, NotificationLevel}, + palette::CommandPaletteState, + }, + layout::AlignmentHeaderLayout, + ui_state::MouseSelection, + }, + }; + + fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { + libmsa::RawSequence { + id: id.to_string(), + sequence: sequence.to_vec(), + } + } + + fn alignment_model(sequences: Vec) -> AlignmentModel { + let alignment = libmsa::Alignment::new(sequences).unwrap(); + AlignmentModel::new(alignment).unwrap() + } + + fn metrics_with( + view: StatsView, + consensus: &[u8], + conservation: Option, + ) -> ColumnStatsCache { + let mut cache = ColumnStatsCache::default(); + match view { + StatsView::Raw => cache.init(consensus.len()), + StatsView::Translated(frame) => { + cache.init(consensus.len() * 3); + let _ = + cache.translated_chunks_to_spawn(&(0..consensus.len()), frame, consensus.len()); + } + } + + let summaries = consensus + .iter() + .enumerate() + .map(|(position, &byte)| libmsa::ColumnSummary { + position, + consensus: Some(byte), + conservation, + }) + .collect(); + let generation = cache.generation; + let chunk_idx = 0; + let stored = cache.store(StatsJobResult { + generation, + chunk_idx, + view, + summaries: Ok(summaries), + }); + assert!(stored); + cache + } + + fn ui_state() -> UiState { + let mut ui = UiState::new(StartupState::default()); + ui.meta.loading_state = LoadingState::Loaded; + ui + } + + fn render_text( + alignment: Option<&AlignmentModel>, + ui: &UiState, + stats_cache: &ColumnStatsCache, + area: Rect, + ) -> String { + let backend = TestBackend::new(area.width, area.height); + let mut terminal = Terminal::new(backend).unwrap(); + let frame_layout = FrameLayout::new(area); + let layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + + terminal + .draw(|frame| { + render( + frame, + alignment, + None, + ui, + stats_cache, + &frame_layout, + &layout, + ); + }) + .unwrap(); + + buffer_text(terminal.backend().buffer(), area) + } + + fn buffer_text(buffer: &Buffer, area: Rect) -> String { + let mut lines = Vec::new(); + + for y in area.top()..area.bottom() { + let mut line = String::new(); + for x in area.left()..area.right() { + let symbol = buffer[(x, y)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + while line.ends_with(' ') { + line.pop(); + } + lines.push(line); + } + + while matches!(lines.last(), Some(last) if last.is_empty()) { + lines.pop(); + } + + lines.join("\n") + } + + fn set_viewport(ui: &mut UiState, alignment: &AlignmentModel, area: Rect) { + let frame_layout = FrameLayout::new(area); + let layout = AppLayout::new( + frame_layout.content_area, + 0, + AlignmentHeaderLayout::without_features(), + ); + ui.viewport.update_dimensions( + layout.alignment_pane_sequence_rows.width as usize, + layout.alignment_pane_sequence_rows.height as usize, + layout.sequence_id_pane.width.saturating_sub(2) as usize, + ); + ui.viewport.set_bounds( + alignment.view().row_count(), + alignment.view().column_count(), + alignment.base().max_id_len(), + ); + } + + #[test] + fn render_empty_state_snapshots() { + let area = Rect::new(0, 0, 100, 24); + + let idle_ui = UiState::new(StartupState::default()); + insta::assert_snapshot!( + "render_empty_idle", + render_text(None, &idle_ui, &ColumnStatsCache::default(), area) + ); + + let mut failed_ui = UiState::new(StartupState::default()); + failed_ui.meta.loading_state = LoadingState::Failed("boom".to_string()); + insta::assert_snapshot!( + "render_empty_failed", + render_text(None, &failed_ui, &ColumnStatsCache::default(), area) + ); + } + + #[test] + fn render_loaded_alignment_snapshots() { + let area = Rect::new(0, 0, 100, 24); + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATGATCATCATCAT"), + raw("seq3", b"CATCATCATCATGATCAT"), + raw("seq4", b"CATCATCATCATCATCAT"), + ]); + let metrics = metrics_with(StatsView::Raw, b"CATCATCATCATCATCAT", Some(1.0)); + let mut ui = ui_state(); + set_viewport(&mut ui, &alignment, area); + + insta::assert_snapshot!( + "render_loaded_basic", + render_text(Some(&alignment), &ui, &metrics, area) + ); + + let mut selection_ui = ui_state(); + set_viewport(&mut selection_ui, &alignment, area); + selection_ui.selection = Some(MouseSelection { + sequence_id: 1, + column: 2, + end_sequence_id: 2, + end_column: 8, + }); + insta::assert_snapshot!( + "render_loaded_with_selection_status", + render_text(Some(&alignment), &selection_ui, &metrics, area) + ); + } + + #[test] + fn render_loaded_translation_snapshot() { + let area = Rect::new(0, 0, 100, 24); + let mut alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATGATCATCATCAT"), + raw("seq3", b"CATCATCATCATGATCAT"), + ]); + alignment.set_reference(0).unwrap(); + alignment + .set_translation(Some(libmsa::ReadingFrame::Frame1)) + .unwrap(); + alignment.diff_mode = DiffMode::Reference; + let metrics = metrics_with( + StatsView::Translated(libmsa::ReadingFrame::Frame1), + b"HHHHHH", + Some(1.0), + ); + let mut ui = ui_state(); + set_viewport(&mut ui, &alignment, area); + + insta::assert_snapshot!( + "render_loaded_translation", + render_text(Some(&alignment), &ui, &metrics, area) + ); + } + + #[test] + fn render_notification_snapshot() { + let area = Rect::new(0, 0, 100, 24); + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + ]); + let metrics = metrics_with(StatsView::Raw, b"CATCATCATCATCATCAT", Some(1.0)); + let mut ui = ui_state(); + set_viewport(&mut ui, &alignment, area); + ui.notification = Some(Notification { + level: NotificationLevel::Info, + message: "Loaded alignment".to_string(), + }); + + insta::assert_snapshot!( + "render_notification", + render_text(Some(&alignment), &ui, &metrics, area) + ); + } + + #[test] + fn render_command_palette_snapshot() { + let area = Rect::new(0, 0, 100, 24); + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCAT"), + ]); + let metrics = metrics_with(StatsView::Raw, b"CATCATCATCATCATCAT", Some(1.0)); + let mut ui = ui_state(); + set_viewport(&mut ui, &alignment, area); + ui.layers.open_palette(CommandPaletteState::empty()); + + insta::assert_snapshot!( + "render_command_palette", + render_text(Some(&alignment), &ui, &metrics, area) + ); + } + + #[test] + fn render_minimap_snapshot() { + let area = Rect::new(0, 0, 100, 24); + let alignment = alignment_model(vec![ + raw("seq1", b"CATCATCATCATCATCATCATCATCATCATCATCAT"), + raw("seq2", b"CATCATCATCATCATCATCATCATCATCATCATCAT"), + raw("seq3", b"CATCATCATCATCATCATCATCATCATCATCATCAT"), + ]); + let metrics = metrics_with( + StatsView::Raw, + b"CATCATCATCATCATCATCATCATCATCATCATCAT", + Some(1.0), + ); + let mut ui = ui_state(); + set_viewport(&mut ui, &alignment, area); + ui.layers.toggle_minimap(); + + insta::assert_snapshot!( + "render_minimap", + render_text(Some(&alignment), &ui, &metrics, area) + ); + } +} diff --git a/salti/src/ui/rows.rs b/salti/src/ui/rows.rs index 18d07f3..b384535 100644 --- a/salti/src/ui/rows.rs +++ b/salti/src/ui/rows.rs @@ -1,8 +1,11 @@ use std::ops::Range; -use crate::config::theme::SequenceTheme; -use ratatui::style::Stylize; -use ratatui::text::Span; +use ratatui::{style::Stylize, text::Span}; + +use crate::{ + config::theme::SequenceTheme, + core::codon::{TranslatedByteRange, TranslatedDiffRange, TranslationOverlay}, +}; /// Lookup table that maps each byte value (`0-255`) to a str for display. /// @@ -33,32 +36,6 @@ pub struct RowRenderMode<'a> { pub diff_against: Option<&'a [u8]>, } -#[derive(Debug, Clone, Copy)] -pub struct TranslatedByteRange<'a> { - start: usize, - bytes: &'a [u8], -} - -impl<'a> TranslatedByteRange<'a> { - pub fn new(start: usize, bytes: &'a [u8]) -> Self { - Self { start, bytes } - } - - fn byte_at(self, protein_col: usize) -> Option { - let offset = protein_col.checked_sub(self.start)?; - self.bytes.get(offset).copied() - } -} - -pub type TranslatedDiffRange<'a> = TranslatedByteRange<'a>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct VisibleCodon { - protein_col: usize, - nuc_start: usize, - centre: usize, -} - #[inline] fn span_for_sequence_byte( sequence_byte: u8, @@ -71,41 +48,26 @@ fn span_for_sequence_byte( } #[inline] -fn format_visible_bytes( - bytes: &[u8], +fn format_byte_iter_spans( + bytes: impl Iterator, sequence_theme: &SequenceTheme, - alignment_type: libmsa::AlignmentType, -) -> Vec> { - bytes - .iter() - .map(|&byte| span_for_sequence_byte(byte, sequence_theme, alignment_type)) - .collect() -} - -#[inline] -fn format_visible_bytes_with_diff( - bytes: &[u8], - diff_against: &[u8], - sequence_theme: &SequenceTheme, - alignment_type: libmsa::AlignmentType, + mode: RowRenderMode<'_>, ) -> Vec> { - assert_eq!( - bytes.len(), - diff_against.len(), - "diff bytes must match the visible width" - ); - - bytes - .iter() - .zip(diff_against.iter()) - .map(|(&byte, &diff_byte)| { - if byte == diff_byte { - ".".fg(sequence_theme.diff_match) - } else { - span_for_sequence_byte(byte, sequence_theme, alignment_type) - } - }) - .collect() + match mode.diff_against { + Some(diff_against) => bytes + .zip(diff_against.iter().copied()) + .map(|(byte, diff_byte)| { + if byte == diff_byte { + ".".fg(sequence_theme.diff_match) + } else { + span_for_sequence_byte(byte, sequence_theme, mode.alignment_type) + } + }) + .collect(), + None => bytes + .map(|byte| span_for_sequence_byte(byte, sequence_theme, mode.alignment_type)) + .collect(), + } } pub fn format_row_spans( @@ -113,75 +75,33 @@ pub fn format_row_spans( sequence_theme: &SequenceTheme, mode: RowRenderMode<'_>, ) -> Vec> { - match mode.diff_against { - Some(diff_against) => format_visible_bytes_with_diff( - visible_bytes, - diff_against, - sequence_theme, - mode.alignment_type, - ), - None => format_visible_bytes(visible_bytes, sequence_theme, mode.alignment_type), - } + format_byte_iter_spans(visible_bytes.iter().copied(), sequence_theme, mode) } -fn complete_protein_len(frame: libmsa::ReadingFrame, nucleotide_len: usize) -> usize { - nucleotide_len.saturating_sub(frame.offset()) / 3 -} - -pub fn visible_protein_range( - visible_nucleotide_range: &Range, - frame: libmsa::ReadingFrame, - nucleotide_len: usize, -) -> Option> { - let last_visible_col = visible_nucleotide_range.end.checked_sub(1)?; - if last_visible_col < frame.offset() { - return None; - } - - let protein_len = complete_protein_len(frame, nucleotide_len); - if protein_len == 0 { - return None; - } - - let start = visible_nucleotide_range - .start - .saturating_sub(frame.offset()) - / 3; - let end = ((last_visible_col - frame.offset()) / 3 + 1).min(protein_len); - - (start < end).then_some(start..end) -} +pub fn format_row_view_spans( + sequence: libmsa::RowView<'_>, + col_range: &Range, + sequence_theme: &SequenceTheme, + mode: RowRenderMode<'_>, +) -> Vec> { + let bytes = sequence + .indexed_bytes_range(col_range.clone()) + .expect("viewport range must be within the current view") + .map(|(_, byte)| byte); -fn visible_codons( - visible_nucleotide_range: &Range, - frame: libmsa::ReadingFrame, - nucleotide_len: usize, -) -> impl Iterator { - visible_protein_range(visible_nucleotide_range, frame, nucleotide_len) - .into_iter() - .flatten() - .map(move |protein_col| { - let nuc_start = frame.offset() + protein_col * 3; - VisibleCodon { - protein_col, - nuc_start, - centre: nuc_start + 1, - } - }) + format_byte_iter_spans(bytes, sequence_theme, mode) } pub fn format_translated_row_spans( sequence: libmsa::TranslatedSequenceView<'_>, visible_nucleotide_range: &Range, - nucleotide_len: usize, - frame: libmsa::ReadingFrame, + overlay: &TranslationOverlay, sequence_theme: &SequenceTheme, diff_against: Option>, ) -> Vec> { format_translated_spans( visible_nucleotide_range, - nucleotide_len, - frame, + overlay, sequence_theme, diff_against, |protein_col| sequence.byte_at(protein_col), @@ -191,15 +111,13 @@ pub fn format_translated_row_spans( pub fn format_translated_byte_range_spans( bytes: TranslatedByteRange<'_>, visible_nucleotide_range: &Range, - nucleotide_len: usize, - frame: libmsa::ReadingFrame, + overlay: &TranslationOverlay, sequence_theme: &SequenceTheme, diff_against: Option>, ) -> Vec> { format_translated_spans( visible_nucleotide_range, - nucleotide_len, - frame, + overlay, sequence_theme, diff_against, |protein_col| bytes.byte_at(protein_col), @@ -208,8 +126,7 @@ pub fn format_translated_byte_range_spans( fn format_translated_spans( visible_nucleotide_range: &Range, - nucleotide_len: usize, - frame: libmsa::ReadingFrame, + overlay: &TranslationOverlay, sequence_theme: &SequenceTheme, diff_against: Option>, mut byte_at: impl FnMut(usize) -> Option, @@ -217,8 +134,10 @@ fn format_translated_spans( let width = visible_nucleotide_range.len(); let mut spans = vec![Span::raw(" "); width]; - for codon in visible_codons(visible_nucleotide_range, frame, nucleotide_len) { - let residue = byte_at(codon.protein_col).expect("visible codon must resolve"); + for codon in overlay.visible_codons(visible_nucleotide_range) { + let Some(residue) = byte_at(codon.protein_col) else { + continue; + }; let translated_style = sequence_theme.style_for(residue, libmsa::AlignmentType::Protein); let diff_matches = diff_against.and_then(|diff| diff.byte_at(codon.protein_col)) == Some(residue); @@ -249,8 +168,7 @@ fn format_translated_spans( spans } -/// Collects visible bytes from a sequence view for the given relative column range. -pub fn visible_bytes(sequence: libmsa::SequenceView<'_>, col_range: &Range) -> Vec { +pub fn visible_bytes(sequence: libmsa::RowView<'_>, col_range: &Range) -> Vec { if col_range.is_empty() { return Vec::new(); } @@ -265,6 +183,7 @@ pub fn visible_bytes(sequence: libmsa::SequenceView<'_>, col_range: &Range libmsa::RawSequence { libmsa::RawSequence { @@ -277,13 +196,11 @@ mod tests { spans.iter().map(|span| span.content.as_ref()).collect() } - #[test] - fn visible_protein_range_includes_complete_codons_overlapping_window() { - let range = visible_protein_range(&(1..8), libmsa::ReadingFrame::Frame1, 9); - assert_eq!(range, Some(0..3)); - - let range = visible_protein_range(&(0..2), libmsa::ReadingFrame::Frame3, 9); - assert!(range.is_none()); + fn overlay(frame: libmsa::ReadingFrame, nucleotide_len: usize) -> TranslationOverlay { + TranslationOverlay { + frame, + nucleotide_len, + } } #[test] @@ -293,14 +210,13 @@ mod tests { let sequence = alignment .translated(libmsa::ReadingFrame::Frame1) .expect("DNA alignment should translate") - .sequence_by_absolute(0) - .expect("visible row should resolve"); + .project_absolute_row(0) + .expect("row should resolve"); let spans = format_translated_row_spans( sequence, &(0..9), - 9, - libmsa::ReadingFrame::Frame1, + &overlay(libmsa::ReadingFrame::Frame1, 9), &crate::config::theme::EVERFOREST_DARK.sequence, None, ); @@ -316,15 +232,14 @@ mod tests { let sequence = alignment .translated(libmsa::ReadingFrame::Frame1) .expect("DNA alignment should translate") - .sequence_by_absolute(0) - .expect("visible row should resolve"); + .project_absolute_row(0) + .expect("row should resolve"); let diff_against = TranslatedDiffRange::new(0, b"MKF"); let spans = format_translated_row_spans( sequence, &(0..9), - 9, - libmsa::ReadingFrame::Frame1, + &overlay(libmsa::ReadingFrame::Frame1, 9), &crate::config::theme::EVERFOREST_DARK.sequence, Some(diff_against), ); @@ -339,15 +254,14 @@ mod tests { let sequence = alignment .translated(libmsa::ReadingFrame::Frame1) .expect("DNA alignment should translate") - .sequence_by_absolute(0) - .expect("visible row should resolve"); + .project_absolute_row(0) + .expect("row should resolve"); let diff_against = TranslatedDiffRange::new(0, b"MKF"); let spans = format_translated_row_spans( sequence, &(0..9), - 9, - libmsa::ReadingFrame::Frame1, + &overlay(libmsa::ReadingFrame::Frame1, 9), &crate::config::theme::EVERFOREST_DARK.sequence, Some(diff_against), ); @@ -365,14 +279,13 @@ mod tests { let sequence = alignment .translated(libmsa::ReadingFrame::Frame2) .expect("DNA alignment should translate") - .sequence_by_absolute(0) - .expect("visible row should resolve"); + .project_absolute_row(0) + .expect("row should resolve"); let spans = format_translated_row_spans( sequence, &(0..9), - 9, - libmsa::ReadingFrame::Frame2, + &overlay(libmsa::ReadingFrame::Frame2, 9), &crate::config::theme::EVERFOREST_DARK.sequence, None, ); diff --git a/salti/src/ui/selection.rs b/salti/src/ui/selection.rs index 394de6a..043a1c5 100644 --- a/salti/src/ui/selection.rs +++ b/salti/src/ui/selection.rs @@ -1,26 +1,23 @@ use std::ops::Range; -use ratatui::layout::Rect; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Color::Rgb, Style}, + widgets::Block, +}; use crate::{ - core::{Viewport, model::AlignmentModel}, - ui::{layout::pinned_section_layout, ui_state::MouseSelection}, + core::{Viewport, codon::TranslationOverlay, model::AlignmentModel}, + ui::{ + layout::{AppLayout, pinned_section_layout}, + ui_state::{MouseSelection, UiState}, + }, }; -pub fn codon_span_for_absolute_column( - absolute_col: usize, - frame: libmsa::ReadingFrame, - nucleotide_len: usize, -) -> Option> { - let offset = frame.offset(); - if absolute_col < offset { - return None; - } - - let codon_start = offset + ((absolute_col - offset) / 3) * 3; - let codon_end = codon_start + 3; - (codon_end <= nucleotide_len).then_some(codon_start..codon_end) -} +const SELECTION_ROW_HIGHLIGHT_ALPHA: f32 = 0.3; +const SELECTION_ROW_TINT_ALPHA: f32 = 0.22; +const SELECTION_COL_HIGHLIGHT_ALPHA: f32 = 0.28; pub fn selection_point_crosshair( alignment: &AlignmentModel, @@ -68,19 +65,211 @@ pub fn selection_row_bounds(selection: MouseSelection) -> (usize, usize) { (start.min(end), start.max(end)) } +pub fn render_mouse_selection( + f: &mut Frame, + layout: &AppLayout, + alignment: &AlignmentModel, + ui: &UiState, + viewport: &Viewport, +) { + let Some(selection) = ui.selection else { + return; + }; + + let window = viewport.window(); + let id_inner_area = Block::bordered().inner(layout.sequence_id_pane); + let sequence_rows_area = layout.alignment_pane_sequence_rows; + let id_content_y = id_inner_area.y + layout.alignment_header.height(); + let id_end_x = id_inner_area.x.saturating_add(id_inner_area.width); + let sequence_end_x = sequence_rows_area + .x + .saturating_add(sequence_rows_area.width); + let band_layout = pinned_section_layout( + alignment.rows().pinned().len(), + sequence_rows_area.height as usize, + ); + let (row_min, row_max) = selection_row_bounds(selection); + + for (row_offset, &absolute_row) in alignment + .rows() + .pinned() + .iter() + .take(band_layout.pinned_rendered) + .enumerate() + { + if !(row_min..=row_max).contains(&absolute_row) { + continue; + } + + let row_y = sequence_rows_area.y + row_offset as u16; + shader( + f, + id_inner_area, + Rect::new( + id_inner_area.x, + id_content_y + row_offset as u16, + id_end_x.saturating_sub(id_inner_area.x), + 1, + ), + ui.theme.theme.accent, + SELECTION_ROW_HIGHLIGHT_ALPHA, + ); + shader( + f, + sequence_rows_area, + Rect::new( + sequence_rows_area.x, + row_y, + sequence_end_x.saturating_sub(sequence_rows_area.x), + 1, + ), + ui.theme.theme.surface_bg, + SELECTION_ROW_TINT_ALPHA, + ); + } + + let scroll_start_y = sequence_rows_area.y + + band_layout.pinned_rendered as u16 + + band_layout.divider_height as u16; + for (row_offset, relative_row) in window.row_range.clone().enumerate() { + let Some(absolute_row) = alignment.view().absolute_row_id(relative_row) else { + continue; + }; + if !(row_min..=row_max).contains(&absolute_row) { + continue; + } + + let row_y = scroll_start_y + row_offset as u16; + shader( + f, + id_inner_area, + Rect::new( + id_inner_area.x, + id_content_y + + band_layout.pinned_rendered as u16 + + band_layout.divider_height as u16 + + row_offset as u16, + id_end_x.saturating_sub(id_inner_area.x), + 1, + ), + ui.theme.theme.accent, + SELECTION_ROW_HIGHLIGHT_ALPHA, + ); + shader( + f, + sequence_rows_area, + Rect::new( + sequence_rows_area.x, + row_y, + sequence_end_x.saturating_sub(sequence_rows_area.x), + 1, + ), + ui.theme.theme.surface_bg, + SELECTION_ROW_TINT_ALPHA, + ); + } + + if let Some(visible_col_range) = + selection_visible_col_range(selection, alignment, &window.col_range) + { + let start_x = + sequence_rows_area.x + (visible_col_range.start - window.col_range.start) as u16; + let end_x_exclusive = + sequence_rows_area.x + (visible_col_range.end - window.col_range.start) as u16; + shader( + f, + sequence_rows_area, + Rect::new( + start_x, + sequence_rows_area.y, + end_x_exclusive.saturating_sub(start_x), + sequence_rows_area.height, + ), + ui.theme.theme.panel_bg, + SELECTION_COL_HIGHLIGHT_ALPHA, + ); + } +} + pub fn selection_visible_col_range( selection: MouseSelection, alignment: &AlignmentModel, visible_col_range: &Range, ) -> Option> { - match alignment.translation() { - Some(frame) => { - translated_selection_visible_col_range(selection, alignment, visible_col_range, frame) - } + match alignment.translation_overlay() { + Some(overlay) => translated_selection_visible_col_range( + selection, + alignment, + visible_col_range, + &overlay, + ), None => raw_selection_visible_col_range(selection, alignment, visible_col_range), } } +fn interpolate(from: u8, to: u8, alpha: f32) -> u8 { + let from = f32::from(from); + let to = f32::from(to); + (from + (to - from) * alpha).round().clamp(0.0, 255.0) as u8 +} + +fn blend_background(base: Color, tint: Color, alpha: f32) -> Option { + match (base, tint) { + (Rgb(red, green, blue), Rgb(red_tint, green_tint, blue_tint)) => Some(Rgb( + interpolate(red, red_tint, alpha), + interpolate(green, green_tint, alpha), + interpolate(blue, blue_tint, alpha), + )), + _ => None, + } +} + +fn shade_selected_cell(cell: &mut ratatui::buffer::Cell, tint: Color, alpha: f32) { + match blend_background(cell.bg, tint, alpha) { + Some(background) => { + cell.set_bg(background); + } + None => { + cell.set_style(Style::new().reversed()); + } + } +} + +fn shader( + f: &mut Frame, + clip_area: Rect, + tint_area: Rect, + tint: ratatui::style::Color, + alpha: f32, +) { + if alpha <= 0.0 || clip_area.width == 0 || clip_area.height == 0 { + return; + } + + let x_start = tint_area.x.max(clip_area.x); + let x_end = tint_area + .x + .saturating_add(tint_area.width) + .min(clip_area.x.saturating_add(clip_area.width)); + let y_start = tint_area.y.max(clip_area.y); + let y_end = tint_area + .y + .saturating_add(tint_area.height) + .min(clip_area.y.saturating_add(clip_area.height)); + if x_start >= x_end || y_start >= y_end { + return; + } + + let buffer = f.buffer_mut(); + for y in y_start..y_end { + for x in x_start..x_end { + if let Some(cell) = buffer.cell_mut((x, y)) { + shade_selected_cell(cell, tint, alpha); + } + } + } +} + fn raw_selection_visible_col_range( selection: MouseSelection, alignment: &AlignmentModel, @@ -112,12 +301,11 @@ fn translated_selection_visible_col_range( selection: MouseSelection, alignment: &AlignmentModel, visible_col_range: &Range, - frame: libmsa::ReadingFrame, + overlay: &TranslationOverlay, ) -> Option> { let selection_start = selection.column.min(selection.end_column); let selection_end = selection.column.max(selection.end_column) + 1; let view = alignment.view(); - let nucleotide_len = view.column_count(); let mut rel_start: Option = None; let mut rel_end: Option = None; @@ -127,7 +315,7 @@ fn translated_selection_visible_col_range( let Some(abs) = view.absolute_column_id(rel) else { continue; }; - let Some(codon_span) = codon_span_for_absolute_column(abs, frame, nucleotide_len) else { + let Some(codon_span) = overlay.codon_span(abs) else { continue; }; if previous_codon_start == Some(codon_span.start) { @@ -141,13 +329,15 @@ fn translated_selection_visible_col_range( let clipped_start = codon_span.start.max(visible_col_range.start); let clipped_end = codon_span.end.min(visible_col_range.end); - let start = view - .relative_column_id(clipped_start) - .expect("translated mode requires a full visible column set"); - let end = view + let Some(start) = view.relative_column_id(clipped_start) else { + continue; + }; + let Some(end) = view .relative_column_id(clipped_end - 1) - .expect("translated mode requires a full visible column set") - + 1; + .map(|relative_col| relative_col + 1) + else { + continue; + }; rel_start = Some(rel_start.map_or(start, |current| current.min(start))); rel_end = Some(rel_end.map_or(end, |current| current.max(end))); } @@ -157,9 +347,9 @@ fn translated_selection_visible_col_range( #[cfg(test)] mod tests { + use super::*; - use crate::core::Viewport; - use crate::core::model::AlignmentModel; + use crate::core::{Viewport, model::AlignmentModel}; fn raw(id: &str, sequence: &[u8]) -> libmsa::RawSequence { libmsa::RawSequence { @@ -222,24 +412,6 @@ mod tests { assert!(range.is_none()); } - #[test] - fn codon_span_maps_any_column_in_the_same_codon() { - let frame = libmsa::ReadingFrame::Frame1; - - assert_eq!(codon_span_for_absolute_column(0, frame, 9), Some(0..3)); - assert_eq!(codon_span_for_absolute_column(1, frame, 9), Some(0..3)); - assert_eq!(codon_span_for_absolute_column(2, frame, 9), Some(0..3)); - assert_eq!(codon_span_for_absolute_column(3, frame, 9), Some(3..6)); - } - - #[test] - fn codon_span_returns_none_for_partial_frame_edges() { - let frame = libmsa::ReadingFrame::Frame2; - - assert_eq!(codon_span_for_absolute_column(0, frame, 9), None); - assert_eq!(codon_span_for_absolute_column(8, frame, 9), None); - } - #[test] fn translated_selection_visible_col_range_expands_to_overlapping_codon() { let alignment = @@ -340,11 +512,9 @@ mod tests { viewport.set_bounds(3, 4, 2); let area = Rect::new(0, 0, 4, 3); - // No pinned rows, so row 0 maps to absolute row 0. let result = selection_point_crosshair(&model, &viewport, area, 0, 0); assert_eq!(result, Some((0, 0))); - // Row 2, col 3. let result = selection_point_crosshair(&model, &viewport, area, 3, 2); assert_eq!(result, Some((2, 3))); } @@ -353,25 +523,18 @@ mod tests { fn crosshair_handles_pinned_band() { let mut model = alignment_model(&["s1", "s2", "s3", "s4"]); model.pin(0).expect("should pin"); - // View now has rows [1, 2, 3] (row 0 excluded). - // Pinned band: 1 row (row 0). - // Divider: 1 row. - // Scrollable: remaining rows. let mut viewport = Viewport::default(); viewport.update_dimensions(4, 2, 2); viewport.set_bounds(3, 4, 2); let area = Rect::new(0, 0, 4, 4); - // Row offset 0 -> pinned row 0 -> absolute row 0. let result = selection_point_crosshair(&model, &viewport, area, 0, 0); assert_eq!(result, Some((0, 0))); - // Row offset 1 -> divider -> None. let result = selection_point_crosshair(&model, &viewport, area, 0, 1); assert!(result.is_none()); - // Row offset 2 -> scrollable row 0 -> view relative 0 -> absolute row 1. let result = selection_point_crosshair(&model, &viewport, area, 0, 2); assert_eq!(result, Some((1, 0))); } diff --git a/salti/src/ui/sequence_id_pane.rs b/salti/src/ui/sequence_id_pane.rs deleted file mode 100644 index c4f6174..0000000 --- a/salti/src/ui/sequence_id_pane.rs +++ /dev/null @@ -1,130 +0,0 @@ -use crate::{ - core::{model::AlignmentModel, viewport::ViewportWindow}, - ui::{ - layout::{AppLayout, RULER_HEIGHT_ROWS, pinned_section_layout}, - ui_state::ThemeState, - }, -}; -use ratatui::Frame; -use ratatui::style::{Style, Styled}; -use ratatui::symbols::merge::MergeStrategy; -use ratatui::text::Line; -use ratatui::widgets::{Block, Paragraph}; - -fn build_sequence_id_line( - theme: &ThemeState, - absolute_row: usize, - alignment_id: &str, - name_offset: usize, - name_width: usize, - id_style: Style, -) -> Line<'static> { - let number_prefix = format!("{} ", absolute_row + 1).set_style(theme.styles.success); - // sequence IDs can be longer than the visible sequence ID pane width. - let id_slice: String = alignment_id - .chars() - .skip(name_offset) - .take(name_width) - .collect(); - - Line::from(vec![number_prefix, id_slice.set_style(id_style)]) -} - -fn build_pinned_divider_line(width: usize, style: Style) -> Line<'static> { - Line::from("─".repeat(width).set_style(style)) -} - -fn render_sequence_id_rows( - f: &mut Frame, - alignment: &AlignmentModel, - window: &ViewportWindow, - theme: &ThemeState, - area: ratatui::layout::Rect, -) { - let ruler_height = usize::from(RULER_HEIGHT_ROWS); - let available_content_height = area.height.saturating_sub(RULER_HEIGHT_ROWS) as usize; - let band_layout = - pinned_section_layout(alignment.rows().pinned().len(), available_content_height); - let mut lines = Vec::with_capacity(ruler_height + area.height as usize); - - let has_pins = !alignment.rows().pinned().is_empty(); - for ruler_row in 0..ruler_height { - if ruler_row == 1 && has_pins { - lines.push(Line::from( - "Pinned sequences:".set_style(theme.styles.text_muted), - )); - } else { - lines.push(Line::from(" ")); - } - } - - let name_width = window - .name_range - .end - .saturating_sub(window.name_range.start); - - for &absolute_row in alignment - .rows() - .pinned() - .iter() - .take(band_layout.pinned_rendered) - { - let Some(sequence) = alignment.base().project_absolute_row(absolute_row) else { - continue; - }; - lines.push(build_sequence_id_line( - theme, - absolute_row, - sequence.id(), - window.name_range.start, - name_width, - theme.styles.accent, - )); - } - - if band_layout.divider_height == 1 { - lines.push(build_pinned_divider_line( - area.width as usize, - theme.styles.border, - )); - } - - for relative_row in window.row_range.clone() { - let Some(sequence) = alignment.view().sequence(relative_row) else { - continue; - }; - lines.push(build_sequence_id_line( - theme, - sequence.absolute_row_id(), - sequence.id(), - window.name_range.start, - name_width, - theme.styles.text, - )); - } - - f.render_widget( - Paragraph::new(lines) - .alignment(ratatui::layout::HorizontalAlignment::Left) - .style(theme.styles.base_block), - area, - ); -} - -pub fn render_sequence_id_pane( - f: &mut Frame, - layout: &AppLayout, - alignment: &AlignmentModel, - window: &ViewportWindow, - theme: &ThemeState, -) { - let block = Block::bordered() - .title(Line::from("Sequence Name".set_style(theme.styles.accent))) - .border_style(theme.styles.border) - .style(theme.styles.base_block) - .merge_borders(MergeStrategy::Exact); - let inner_area = block.inner(layout.sequence_id_pane); - f.render_widget(block, layout.sequence_id_pane); - - render_sequence_id_rows(f, alignment, window, theme, inner_area); -} diff --git a/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_constant_filter.snap b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_constant_filter.snap new file mode 100644 index 0000000..ca16c43 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_constant_filter.snap @@ -0,0 +1,6 @@ +--- +source: salti/src/ui/frame.rs +assertion_line: 285 +expression: "status_text(&build_bottom_status_bar(Some(&constant_filter_alignment),\n&ui_state(),))" +--- +Filters: [constant: >= 100%] (3 rows) (0 cols) diff --git a/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_filters_translation.snap b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_filters_translation.snap new file mode 100644 index 0000000..1feb2d2 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_filters_translation.snap @@ -0,0 +1,6 @@ +--- +source: salti/src/ui/frame.rs +assertion_line: 271 +expression: "status_text(&build_bottom_status_bar(Some(&filtered_alignment), &ui_state()))" +--- +Filters: [rows: seq1|seq2] [gaps: <= 50%] (2 rows) (9 cols) | Translation frame: 2 diff --git a/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_multi_selection.snap b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_multi_selection.snap new file mode 100644 index 0000000..8b1d337 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_multi_selection.snap @@ -0,0 +1,6 @@ +--- +source: salti/src/ui/frame.rs +assertion_line: 301 +expression: "status_text(&build_bottom_status_bar(None, &multi_selection_ui))" +--- +3 sequence(s) selected @ 2-5 diff --git a/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_single_selection.snap b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_single_selection.snap new file mode 100644 index 0000000..cb4a887 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_bottom_status_single_selection.snap @@ -0,0 +1,6 @@ +--- +source: salti/src/ui/frame.rs +assertion_line: 288 +expression: "status_text(&build_bottom_status_bar(Some(&selected_alignment),\n&single_selection_ui))" +--- +Selected: seq1 @ 6 diff --git a/salti/src/ui/snapshots/salti__ui__frame__tests__frame_top_status_failed_load.snap b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_top_status_failed_load.snap new file mode 100644 index 0000000..da169c7 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_top_status_failed_load.snap @@ -0,0 +1,6 @@ +--- +source: salti/src/ui/frame.rs +assertion_line: 248 +expression: "status_text(&build_top_status_bar(None, &failed_ui))" +--- +File: Unknown | Status: Failed | 0 alignments | Length: 0 | Positions: 0-0 diff --git a/salti/src/ui/snapshots/salti__ui__frame__tests__frame_top_status_loaded_alignment.snap b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_top_status_loaded_alignment.snap new file mode 100644 index 0000000..8e4de94 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__frame__tests__frame_top_status_loaded_alignment.snap @@ -0,0 +1,6 @@ +--- +source: salti/src/ui/frame.rs +assertion_line: 240 +expression: "status_text(&build_top_status_bar(Some(&alignment), &loaded_ui))" +--- +File: iamnotreal.fasta | Status: Loaded | 3 alignments | Length: 8 | Positions: 1-5 diff --git a/salti/src/ui/snapshots/salti__ui__render__tests__render_command_palette.snap b/salti/src/ui/snapshots/salti__ui__render__tests__render_command_palette.snap new file mode 100644 index 0000000..9d3cf54 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__render__tests__render_command_palette.snap @@ -0,0 +1,28 @@ +--- +source: salti/src/ui/render.rs +expression: "render_text(Some(&alignment), &ui, &metrics, area)" +--- + File: Unknown | Status: Loaded | 2 alignments | Length: 18 | Positions: 1-18 +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│ │1 10 │ +│ │. . | . │ +│1 seq1 │CATCATCATCATCATCAT │ +│2 seq2 │CATCATCATCATCATCAT │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +jump-position clear-filter reload-as-protein set-translation-frame +jump-sequence filter-gaps check-update set-theme +jump-feature filter-constant quit set-sequence-type +pin-sequence set-reference set-diff-mode load-gff +unpin-sequence clear-reference load-alignment +filter-rows toggle-translate set-consensus-method +:█ diff --git a/salti/src/ui/snapshots/salti__ui__render__tests__render_empty_failed.snap b/salti/src/ui/snapshots/salti__ui__render__tests__render_empty_failed.snap new file mode 100644 index 0000000..684d717 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__render__tests__render_empty_failed.snap @@ -0,0 +1,17 @@ +--- +source: salti/src/ui/render.rs +assertion_line: 448 +expression: "render_text(None, &failed_ui, &ColumnStatsCache::default(), area)" +--- + File: Unknown | Status: Failed | 0 alignments | Length: 0 | Positions: 0-0 + + + + + + + + + + + Failed to load alignment: boom diff --git a/salti/src/ui/snapshots/salti__ui__render__tests__render_empty_idle.snap b/salti/src/ui/snapshots/salti__ui__render__tests__render_empty_idle.snap new file mode 100644 index 0000000..cb798f1 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__render__tests__render_empty_idle.snap @@ -0,0 +1,18 @@ +--- +source: salti/src/ui/render.rs +assertion_line: 441 +expression: "render_text(None, &idle_ui, &ColumnStatsCache::default(), area)" +--- + File: Unknown | Status: Idle | 0 alignments | Length: 0 | Positions: 0-0 + + + + + + + + + salti: A modern MSA browser for the terminal. + Use the command palette to open an alignment. + + Hint: use :load-alignment diff --git a/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_basic.snap b/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_basic.snap new file mode 100644 index 0000000..bf26c5e --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_basic.snap @@ -0,0 +1,26 @@ +--- +source: salti/src/ui/render.rs +expression: "render_text(Some(&alignment), &ui, &metrics, area)" +--- + File: Unknown | Status: Loaded | 4 alignments | Length: 18 | Positions: 1-18 +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│ │1 10 │ +│ │. . | . │ +│1 seq1 │CATCATCATCATCATCAT │ +│2 seq2 │CATCATGATCATCATCAT │ +│3 seq3 │CATCATCATCATGATCAT │ +│4 seq4 │CATCATCATCATCATCAT │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +├──────────────────┼───────────────────────────────────────────────────────────────────────────────┤ +│Reference Sequence│No reference selected │ +│Consensus Sequence│CATCATCATCATCATCAT │ +│Conservation: │██████████████████ │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ diff --git a/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_translation.snap b/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_translation.snap new file mode 100644 index 0000000..b1052c5 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_translation.snap @@ -0,0 +1,27 @@ +--- +source: salti/src/ui/render.rs +expression: "render_text(Some(&alignment), &ui, &metrics, area)" +--- + File: Unknown | Status: Loaded | 2 alignments | Length: 18 | Positions: 1-18 +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│ │1 10 │ +│ │. . | . │ +│2 seq2 │ . . D . . . │ +│3 seq3 │ . . . . D . │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +├──────────────────┼───────────────────────────────────────────────────────────────────────────────┤ +│Reference Sequence│ H H H H H H │ +│Consensus Sequence│ H H H H H H │ +│Conservation: │██████████████████ │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ + Translation frame: 1 diff --git a/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_with_selection_status.snap b/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_with_selection_status.snap new file mode 100644 index 0000000..a10e5bb --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__render__tests__render_loaded_with_selection_status.snap @@ -0,0 +1,27 @@ +--- +source: salti/src/ui/render.rs +expression: "render_text(Some(&alignment), &selection_ui, &metrics, area)" +--- + File: Unknown | Status: Loaded | 4 alignments | Length: 18 | Positions: 1-18 +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│ │1 10 │ +│ │. . | . │ +│1 seq1 │CATCATCATCATCATCAT │ +│2 seq2 │CATCATGATCATCATCAT │ +│3 seq3 │CATCATCATCATGATCAT │ +│4 seq4 │CATCATCATCATCATCAT │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +├──────────────────┼───────────────────────────────────────────────────────────────────────────────┤ +│Reference Sequence│No reference selected │ +│Consensus Sequence│CATCATCATCATCATCAT │ +│Conservation: │██████████████████ │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ + 2 sequence(s) selected @ 3-9 diff --git a/salti/src/ui/snapshots/salti__ui__render__tests__render_minimap.snap b/salti/src/ui/snapshots/salti__ui__render__tests__render_minimap.snap new file mode 100644 index 0000000..f334802 --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__render__tests__render_minimap.snap @@ -0,0 +1,28 @@ +--- +source: salti/src/ui/render.rs +expression: "render_text(Some(&alignment), &ui, &metrics, area)" +--- + File: Unknown | Status: Loaded | 3 alignments | Length: 36 | Positions: 1-36 +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│ │1 10 20 30 │ +│ │. . | . | . | . │ +│1 seq1 │CATCATCATCATCATCATCATCATCATCATCATCAT │ +│2 seq2 │CATCATCATCATCATCATCATCATCATCATCATCAT │ +│3 seq3 │CATCATCATCATCATCATCATCATCATCATCATCAT │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│ +│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│ +│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│ +│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│ +│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +Drag to pan diff --git a/salti/src/ui/snapshots/salti__ui__render__tests__render_notification.snap b/salti/src/ui/snapshots/salti__ui__render__tests__render_notification.snap new file mode 100644 index 0000000..f9cbc8e --- /dev/null +++ b/salti/src/ui/snapshots/salti__ui__render__tests__render_notification.snap @@ -0,0 +1,28 @@ +--- +source: salti/src/ui/render.rs +expression: "render_text(Some(&alignment), &ui, &metrics, area)" +--- + File: Unknown | Status: Loaded | 2 alignments | Length: 18 | Positions: 1-18 +┌──────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│ │1 10 │ +│ │. . | . │ +│1 seq1 │CATCATCATCATCATCAT │ +│2 seq2 │CATCATCATCATCATCAT │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +├──────────────────┼───────────────────────────────────────────────────────────────────────────────┤ +│Reference Sequence│No reference selected │ +│Consensus Sequence│CATCATCATCATCATCAT │ +│Conservation: │██████████████████ │ +└──────────────────┴───────────────────────────────────────────────────────────────────────────────┘ + +Loaded alignment diff --git a/salti/src/ui/ui_state.rs b/salti/src/ui/ui_state.rs index 7a9dcad..b9d4acb 100644 --- a/salti/src/ui/ui_state.rs +++ b/salti/src/ui/ui_state.rs @@ -4,8 +4,10 @@ use crate::{ EVERFOREST_DARK, Theme, ThemeId, ThemeStyles, build_theme_styles, theme_from_id, }, core::Viewport, - overlay::overlay_state::OverlayState, - ui::notification::Notification, + ui::{ + layers::{notification::Notification, state::LayerState}, + panes::gff::GffPaneState, + }, }; #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -71,23 +73,27 @@ impl Default for ThemeState { #[derive(Debug)] pub struct UiState { - pub(crate) overlay: OverlayState, + pub(crate) layers: LayerState, + pub(crate) gff_pane: GffPaneState, pub notification: Option, pub selection: Option, pub theme: ThemeState, pub viewport: Viewport, pub meta: MetaState, + pub gff_tooltip: Option, } impl UiState { pub fn new(startup: StartupState) -> Self { Self { - overlay: OverlayState::default(), + layers: LayerState::default(), + gff_pane: GffPaneState::default(), notification: None, selection: None, theme: ThemeState::default(), viewport: Viewport::default(), meta: MetaState::from(startup), + gff_tooltip: None, } } @@ -101,7 +107,8 @@ impl UiState { pub fn clear_transient_state(&mut self) { self.selection = None; - self.overlay.close(); + self.layers.close_active(); self.notification = None; + self.gff_tooltip = None; } } diff --git a/salti/src/update.rs b/salti/src/update.rs index 03cd317..047c215 100644 --- a/salti/src/update.rs +++ b/salti/src/update.rs @@ -1,6 +1,7 @@ +use std::time::Duration; + use semver::Version; use serde::Deserialize; -use std::time::Duration; const CRATE_NAME: &str = env!("CARGO_PKG_NAME"); const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/tapes/gff.tape b/tapes/gff.tape new file mode 100644 index 0000000..8e9e0fd --- /dev/null +++ b/tapes/gff.tape @@ -0,0 +1,16 @@ +Output assets/gff.gif +Set Width 1300 +Set Height 600 +Hide +Type "cargo run -r nextclade.aligned.fasta +Enter +Show +Sleep 1 +Type ":load-gff genemap.gff" +Sleep 2 +Enter +Sleep 2 +Type ":jump-feature S" +Sleep 2 +Enter +Sleep 3