//! The field of DNA, and the grid it is drawn on. //! //! A field of parallel helices, sheared down and to the right and sampled one //! base pair per row, and the panel that hangs on it. It is the same art //! everywhere atgc shows it — `atgc about` prints it to a terminal, the //! post-login page hangs it behind a card, and atgc.codes shows the panel //! and the field together as what the command draws — and this module is why //! "the same" is a fact rather than an intention: there is one set of //! constants, one [`Canvas`], one [`draw_field`], one [`draw_panel`], and the //! two media differ only in how a finished grid of cells is written out. //! //! # Where the two renderings live //! //! [`Canvas::render`] is here, because a canvas is a terminal thing that a //! browser can also be shown. The markup rendering is in //! [`crate::html::field`], with the page that needs it. Both walk //! [`Canvas::rows`], which is what stops them disagreeing about where a row //! ends, and both switch on a cell's [`Ink`] rather than on a colour: one is //! a key with a type, the other would be a terminal escape that the HTML has //! to match its way back out of. //! //! This was the top half of `cmd/about.rs`, where it sat because `about` drew //! it first. Nothing about it is a command: the OAuth callback page reached //! across into a command module for the field, which is the one import in the //! tree that ran against the direction the layout is meant to have. use anstyle::{AnsiColor, Style}; use std::f64::consts::TAU; use std::fmt::Write as _; /// Half a rung's width where a helix faces us square-on. const AMPLITUDE: f64 = 7.0; /// Columns a helix's axis drifts right per row — the diagonal sweep. const SLOPE: f64 = 2.2; /// Rows per full turn. B-DNA takes about 10.5 base pairs. const PERIOD: f64 = 12.0; /// Rows per half turn. The rung width goes by |sin|, so the field's shape /// repeats on this, not on `PERIOD`. An integer because frame heights are. const HALF_TURN: i32 = PERIOD as i32 / 2; /// Horizontal spacing between neighboring helices in the field. const BAND_GAP: f64 = 18.0; /// Where the field sits relative to the frame's left edge. An aesthetic /// constant, and only that: [`draw_field`] lays down the bands that fall off /// both sides, so no offset ever leaves an edge blank, and the shear walks /// the pattern 2.2 columns a row against an 18-column repeat, so every phase /// reaches both edges somewhere down a frame of any real height. All this /// picks is which rows show the seam between two helices at the edge. It was /// chosen by eye against a 74-column frame; at another width it is neither /// better nor worse, only different. const ORIGIN: f64 = 3.0; /// Rows of twist to skip before the frame's first row. A hair under a /// quarter turn, which puts the top row within a few percent of the helix's /// widest — that much holds at any frame size, since it only concerns row 0. /// /// The bottom row is the part that used to lean on a fixed 19 rows. It comes /// out as wide as the top only when the frame spans a whole number of half /// turns, `(height - 1) % HALF_TURN == 0`; otherwise it lands mid-twist and /// the field visibly narrows along the bottom edge. That was true by /// construction at 19 rows, and it is still true at every size, because /// [`snap_rows`] hands terminal rows back to keep it so. const ROW_PHASE: f64 = 2.5; /// Rows of twist between one helix and its neighbor. A quarter turn, so one /// at its narrow waist sits beside one at full width and no row of the field /// thins out. Half a turn would only swap which strand leads — the rung /// width goes by |sin|, which a half period leaves untouched. const BAND_PHASE: f64 = PERIOD / 4.0; /// Reading down one strand; the other strand is its complement, so all four /// bases appear on both sides over a turn. pub(crate) const STRAND: [char; 4] = ['A', 'T', 'G', 'C']; /// What a cell is drawn in — the key both renderings switch on. /// [`Canvas::render`] maps it to an [`anstyle::Style`], [`crate::html::field`] /// to a CSS class, and neither has to recover a base from the other's colour. /// /// Deliberately not one of the six in [`crate::term::style`]. Those are /// semantic — a caller reaches for `BAD` or `WARN` by what it means — while /// these are the chromatogram colors the four bases are conventionally read /// in, which is a fact about the art. [`Ink::Muted`] renders as `DIM` does, /// and that is exactly the coincidence `term::style` was written to stop /// trusting: retheming atgc's secondary text should not repaint the field's /// hydrogen bonds. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Ink { A, T, G, C, /// Everything that isn't a base: bonds, borders, labels. Muted, /// The terminal's or the page's own color — no escape, no element. Default, } impl Ink { /// The ink a base is drawn in. Anything that is not `A`, `T` or `G` is a /// `C`, matching [`complement`]'s reading of the same alphabet. pub(crate) fn of(base: char) -> Self { match base { 'A' => Ink::A, 'T' => Ink::T, 'G' => Ink::G, _ => Ink::C, } } fn style(self) -> Style { let color = match self { Ink::A => AnsiColor::BrightGreen, Ink::T => AnsiColor::BrightRed, Ink::G => AnsiColor::BrightYellow, Ink::C => AnsiColor::BrightCyan, Ink::Muted => AnsiColor::BrightBlack, Ink::Default => return Style::new(), }; Style::new().fg_color(Some(anstyle::Color::Ansi(color))) } } fn complement(base: char) -> char { match base { 'A' => 'T', 'T' => 'A', 'G' => 'C', _ => 'G', } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct Cell { pub(crate) ch: char, pub(crate) ink: Ink, } const BLANK: Cell = Cell { ch: ' ', ink: Ink::Default, }; pub(crate) struct Canvas { width: i32, height: i32, cells: Vec, } impl Canvas { /// Negative dimensions collapse to an empty canvas rather than wrapping /// around in the cast. Callers clamp before they get here; this is only /// so nothing downstream of a strange terminal report can allocate /// nonsense. [`crate::html::field`]'s page size is the largest product /// ever asked for — a little over the terminal frame's ceiling — so the /// multiply has no room to overflow either. pub(crate) fn new(width: i32, height: i32) -> Self { let (width, height) = (width.max(0), height.max(0)); Canvas { width, height, cells: vec![BLANK; (width * height) as usize], } } /// Writes that fall outside the frame are dropped — this is the crop. pub(crate) fn put(&mut self, x: i32, y: i32, ch: char, ink: Ink) { if (0..self.width).contains(&x) && (0..self.height).contains(&y) { self.cells[(y * self.width + x) as usize] = Cell { ch, ink }; } } pub(crate) fn write(&mut self, x: i32, y: i32, text: &str, ink: Ink) { for (i, ch) in text.chars().enumerate() { self.put(x + i as i32, y, ch, ink); } } pub(crate) fn fill(&mut self, x: i32, y: i32, count: i32, ch: char, ink: Ink) { for i in 0..count { self.put(x + i, y, ch, ink); } } pub(crate) fn clear(&mut self, x: i32, y: i32, width: i32, height: i32) { for row in y..y + height { self.fill(x, row, width, ' ', Ink::Default); } } /// One cell, or `None` outside the frame — the read matching [`put`]'s /// write, and the same crop. /// /// Exists for the callers that check what was drawn rather than draw: /// `cmd::about`'s tests read single cells to confirm the panel punched a /// gutter out of the field and that the tagline coloured the letters it /// meant to. Those reached into `cells` directly while they lived in the /// same file, which they no longer do. /// /// `cfg(test)` and not `pub(crate)` alone: nothing outside a test wants /// to read a canvas back a cell at a time, and a method with no /// production caller is one clippy is right to complain about. #[cfg(test)] pub(crate) fn cell(&self, x: i32, y: i32) -> Option { ((0..self.width).contains(&x) && (0..self.height).contains(&y)) .then(|| self.cells[(y * self.width + x) as usize]) } /// Every cell that carries ink, left to right and top to bottom. For the /// same readers as [`cell`](Canvas::cell), and `cfg(test)` for the same /// reason. #[cfg(test)] pub(crate) fn colored(&self) -> impl Iterator + '_ { self.cells.iter().copied().filter(|c| c.ink != Ink::Default) } /// The grid a row at a time, each already trimmed of its trailing blanks. /// /// Both renderings want exactly this, and having them share it is the /// point: where a row ends decides how much whitespace a page carries and /// whether a terminal's last column is written to, and two `rposition` /// calls that agree today are two that can stop agreeing. pub(crate) fn rows(&self) -> impl Iterator { // `chunks` panics on a zero stride, and a canvas with no columns has // no rows worth walking anyway — so it yields nothing at all, and a // caller collecting lines gets an empty string rather than a run of // bare newlines. let cells: &[Cell] = if self.width == 0 { &[] } else { &self.cells }; cells.chunks(self.width.max(1) as usize).map(|row| { let end = row.iter().rposition(|c| c.ch != ' ').map_or(0, |i| i + 1); &row[..end] }) } /// Collapse the grid into lines, opening an escape only where the ink /// actually changes. /// /// The escapes come from [`anstyle`], as everything atgc paints does: /// `{style}` opens and `{style:#}` resets, which render as the `\x1b[92m` /// and `\x1b[0m` this used to write by hand. pub(crate) fn render(&self, colors: bool) -> String { let mut out = String::new(); for row in self.rows() { let mut open = Ink::Default; for cell in row { if colors && cell.ink != open { if open != Ink::Default { let _ = write!(out, "{:#}", open.style()); } if cell.ink != Ink::Default { let _ = write!(out, "{}", cell.ink.style()); } open = cell.ink; } out.push(cell.ch); } if open != Ink::Default { let _ = write!(out, "{:#}", open.style()); } out.push('\n'); } out } } /// One base pair of one helix, with its hydrogen bonds: `-` for the two that /// hold A to T, `=` for the three that hold G to C. Rungs that hang off the /// frame are cut by it, not skipped. fn draw_rung(canvas: &mut Canvas, row: i32, band: i32) { let y = f64::from(row); let axis = ORIGIN + y * SLOPE + f64::from(band) * BAND_GAP; let phase = y + ROW_PHASE + f64::from(band) * BAND_PHASE; let offset = AMPLITUDE * (phase * TAU / PERIOD).sin(); let base = STRAND[(row + band).rem_euclid(4) as usize]; // The strands swap sides every half turn. let ((left, left_base), (right, right_base)) = if offset < 0.0 { ((axis + offset, base), (axis - offset, complement(base))) } else { ((axis - offset, complement(base)), (axis + offset, base)) }; let (left, right) = (left.round() as i32, right.round() as i32); let bond = if matches!(left_base, 'A' | 'T') { '-' } else { '=' }; canvas.fill(left + 1, row, right - left - 1, bond, Ink::Muted); canvas.put(left, row, left_base, Ink::of(left_base)); canvas.put(right, row, right_base, Ink::of(right_base)); } /// Lay the whole field down, edge to edge. `bands` covers every helix whose /// sweep could cross the frame at any row. pub(crate) fn draw_field(canvas: &mut Canvas) { let reach = f64::from(canvas.width) + 2.0 * AMPLITUDE + SLOPE * f64::from(canvas.height); let bands = (reach / BAND_GAP).ceil() as i32; for band in -bands..=bands { for row in 0..canvas.height { draw_rung(canvas, row, band); } } } /// Trim a height down to one whose last row lands at the same point in the /// twist as its first — see [`ROW_PHASE`]. Heights of 1, 7, 13, 19, 25, 31 /// qualify, so this hands back up to `HALF_TURN - 1` rows of terminal to buy /// a field as wide along the bottom edge as along the top. Cheap at any size /// worth drawing: five rows out of a thirty-row terminal, against a bottom /// edge that would otherwise taper off mid-turn. pub(crate) fn snap_rows(rows: i32) -> i32 { rows - (rows - 1).rem_euclid(HALF_TURN) } // --------------------------------------------------------------------------- // The panel // --------------------------------------------------------------------------- // // What `atgc about` hangs on the field, and what the site draws to show the // terminal. It followed the field out of `cmd/about.rs` for the same reason // the field did: a panel is not a command, and the moment a second caller // wanted one, the only way to reach it ran from `html/` into `cmd/` — the one // direction the module layout does not have. What stayed behind is the part // that is genuinely `about`'s: how big a frame a *terminal* can hold. /// Blank columns held clear either side of the panel. Vertically the field /// runs flush to the border, which reads as depth rather than crowding. pub(crate) const GUTTER: i32 = 2; /// One row of the panel's contents. #[derive(Debug)] pub(crate) enum Line<'a> { Blank, /// Highlights the letters spelling out ATGC: see [`write_tagline`]. Tagline(&'a str), Field(&'a str, &'a str), } pub(crate) const LABEL_WIDTH: usize = 10; /// Blank columns between the panel's border and its text. pub(crate) const PAD: i32 = 2; pub(crate) fn width_of(line: &Line<'_>) -> usize { match line { Line::Blank => 0, Line::Tagline(text) => text.chars().count(), Line::Field(_, value) => LABEL_WIDTH + value.chars().count(), } } /// What the panel says: the tagline, and the three facts about the project /// somebody might act on. /// /// Here rather than in [`crate::cmd::about`] because the terminal on /// atgc.codes is that command's own drawing — `html::site` calls this, under /// the `www` feature — and not a picture of it: a page showing a panel the /// command does not print would be a screenshot of software nobody has. Both `repo` and `site` are read /// out of `Cargo.toml`, so the crate's metadata, the panel and the card a /// login lands on cannot disagree about where atgc lives. pub(crate) fn panel_lines() -> [Line<'static>; 7] { [ Line::Blank, Line::Tagline(env!("CARGO_PKG_DESCRIPTION")), Line::Blank, Line::Field("version", env!("CARGO_PKG_VERSION")), Line::Field("repo", env!("CARGO_PKG_REPOSITORY")), Line::Field("site", env!("CARGO_PKG_HOMEPAGE")), Line::Blank, ] } /// The name, found inside a phrase: A then T then G then C, once each and /// in that order. /// /// This is the whole rule, and the restraint is the point. Colouring every /// a, t, g and c in a line of English paints half of it — those are four of /// the commonest letters there are — and what reads as a wordmark at four /// letters reads as a fault at forty. Matching in order and stopping after /// each one turns it into one quiet claim instead: the name is in here, if /// you look. "ATproto Git Client. A CLI for Tangled repos and pull /// requests." picks out exactly its four initials and leaves the C of /// "CLI" and the T of "Tangled" alone. /// /// Case is ignored, so the rule reads a heading as happily as a tagline. /// It changes nothing about the tagline itself, whose four letters are the /// first of their kind in the line either way. /// /// One rule and one implementation, because the terminal and the browser /// have to agree about which letters those are: [`write_tagline`] draws the /// answer on a canvas and `html::site` wraps it in an element. #[derive(Debug)] pub(crate) struct Spelling { found: usize, } impl Spelling { pub(crate) fn new() -> Self { Spelling { found: 0 } } /// The ink this character takes: a base's if it is the next letter of /// the name still to be found, and nothing otherwise. pub(crate) fn take(&mut self, ch: char) -> Option { let wanted = *STRAND.get(self.found)?; if ch.eq_ignore_ascii_case(&wanted) { self.found += 1; Some(Ink::of(wanted)) } else { None } } } /// One line of the panel with the name picked out of it, per [`Spelling`]. pub(crate) fn write_tagline(canvas: &mut Canvas, x: i32, y: i32, text: &str) { let mut spelling = Spelling::new(); for (i, ch) in text.chars().enumerate() { let ink = spelling.take(ch).unwrap_or(Ink::Default); canvas.put(x + i as i32, y, ch, ink); } } /// One line of the panel's contents, with `x` at its first text column. fn draw_line(canvas: &mut Canvas, x: i32, y: i32, line: &Line<'_>) { match line { Line::Blank => {} Line::Tagline(body) => write_tagline(canvas, x, y, body), Line::Field(label, value) => { canvas.write(x, y, label, Ink::Muted); canvas.write(x + LABEL_WIDTH as i32, y, value, Ink::Default); } } } /// Punch the panel out of the field and draw it: a rounded box with the /// wordmark set into its top edge. pub(crate) fn draw_panel(canvas: &mut Canvas, x: i32, y: i32, width: i32, lines: &[Line<'_>]) { let height = lines.len() as i32 + 2; let bottom = y + height - 1; canvas.clear(x - GUTTER, y, width + 2 * GUTTER, height); canvas.fill(x, y, width, '─', Ink::Muted); canvas.fill(x, bottom, width, '─', Ink::Muted); canvas.put(x, y, '╭', Ink::Muted); canvas.put(x + width - 1, y, '╮', Ink::Muted); canvas.put(x, bottom, '╰', Ink::Muted); canvas.put(x + width - 1, bottom, '╯', Ink::Muted); // "atgc" inset in the top edge, each letter in its base's color. let title = x + 3; canvas.put(title - 1, y, ' ', Ink::Muted); for (i, base) in STRAND.iter().enumerate() { let color = Ink::of(*base); canvas.put(title + i as i32, y, base.to_ascii_lowercase(), color); } canvas.put(title + STRAND.len() as i32, y, ' ', Ink::Muted); for (i, line) in lines.iter().enumerate() { let row = y + 1 + i as i32; canvas.put(x, row, '│', Ink::Muted); canvas.put(x + width - 1, row, '│', Ink::Muted); draw_line(canvas, x + 1 + PAD, row, line); } } /// The panel's contents with nothing around them: no border, no field, no /// centering. For a terminal too small to hold the frame, where the choice /// is between art with pieces missing and text that is merely plain. /// /// The canvas is only as wide as the text, so nothing is cropped, and a line /// longer than the terminal is left for the terminal to wrap. Wrapping loses /// less than truncating would: the repo URL is the one thing here somebody /// might want to copy. Spacer lines are dropped, since on a five-row /// terminal every row has to earn its place. pub(crate) fn draw_bare(lines: &[Line<'_>], content: i32) -> Canvas { let body: Vec<&Line<'_>> = lines.iter().filter(|line| width_of(line) > 0).collect(); let mut canvas = Canvas::new(content, body.len() as i32); for (row, line) in body.into_iter().enumerate() { draw_line(&mut canvas, 0, row as i32, line); } canvas } #[cfg(test)] mod tests { use super::*; /// The frame ceiling `cmd::about` draws at, repeated rather than imported /// so the canvas tests exercise the largest grid ever asked for without /// `art` depending on a command module's constants. If those move, these /// only stop being the interesting size — they do not stop being valid. const FRAME_COLUMNS: i32 = 120; const FRAME_ROWS: i32 = 31; fn lines_of(canvas: &Canvas, colors: bool) -> Vec { canvas.render(colors).lines().map(str::to_string).collect() } // -- frame geometry -------------------------------------------------- /// `snap_rows` exists to make the bottom row of the field land at the /// same point in the twist as the top row, which is true exactly when /// `(height - 1)` is a whole number of half turns. Checked as that /// property over every height the frame can take, rather than as a list /// of numbers that would have to be rewritten if `PERIOD` moved. #[test] fn snapped_heights_span_whole_half_turns() { for rows in 1..=FRAME_ROWS { let snapped = snap_rows(rows); assert_eq!( (snapped - 1).rem_euclid(HALF_TURN), 0, "snap_rows({rows}) = {snapped} lands mid-twist" ); assert!(snapped <= rows, "snap_rows({rows}) = {snapped} grew"); assert!( rows - snapped < HALF_TURN, "snap_rows({rows}) = {snapped} gave back a whole half turn" ); } } // -- canvas ---------------------------------------------------------- #[test] fn writes_that_fall_outside_the_frame_are_cropped() { let mut canvas = Canvas::new(4, 2); canvas.put(-1, 0, 'x', Ink::Default); canvas.put(4, 0, 'x', Ink::Default); canvas.put(0, -1, 'x', Ink::Default); canvas.put(0, 2, 'x', Ink::Default); assert_eq!(canvas.render(false), "\n\n", "nothing should have landed"); // A string straddling the right edge keeps the part that fits, in // the columns it was written to — leading blanks are not trimmed, // only trailing ones. canvas.write(2, 0, "abcd", Ink::Default); assert_eq!(lines_of(&canvas, false)[0], " ab"); } /// A terminal reporting something absurd must not talk the canvas into a /// huge or wrapped-around allocation. #[test] fn a_nonsensical_size_collapses_to_an_empty_canvas() { for (w, h) in [(-1, 5), (5, -1), (-1, -1), (0, 0)] { let canvas = Canvas::new(w, h); assert!( canvas.cells.is_empty(), "{w}x{h} allocated {}", canvas.cells.len() ); } // The largest the frame can ever ask for still multiplies cleanly. let canvas = Canvas::new(FRAME_COLUMNS, FRAME_ROWS); assert_eq!(canvas.cells.len(), (FRAME_COLUMNS * FRAME_ROWS) as usize); } /// `chunks` panics on a zero stride, so a canvas with no columns has to /// short-circuit rather than walk its rows. #[test] fn a_canvas_with_no_columns_renders_nothing() { assert_eq!(Canvas::new(0, 5).render(true), ""); assert_eq!(Canvas::new(0, 5).render(false), ""); } #[test] fn trailing_blanks_are_trimmed_off_every_row() { let mut canvas = Canvas::new(10, 2); canvas.write(0, 0, "hi", Ink::Default); // A row that was never written is empty, not ten spaces. assert_eq!(canvas.render(false), "hi\n\n"); // Interior blanks stay; only the tail goes. canvas.write(0, 1, "a", Ink::Default); canvas.put(3, 1, 'b', Ink::Default); assert_eq!(lines_of(&canvas, false)[1], "a b"); } /// Every ink against the literal escape the canvas wrote before it went /// through [`anstyle`], so this is a test of the bytes that reach a /// terminal rather than a restatement of what `anstyle` renders. #[test] fn each_ink_renders_the_escape_it_always_did() { for (ink, code) in [ (Ink::A, "92"), (Ink::T, "91"), (Ink::G, "93"), (Ink::C, "96"), (Ink::Muted, "90"), ] { let mut canvas = Canvas::new(1, 1); canvas.put(0, 0, 'x', ink); assert_eq!(canvas.render(true), format!("\x1b[{code}mx\x1b[0m\n")); } } /// The point of the collapsing: one escape per run of a color, not one /// per cell. At a full frame that is the difference between a few hundred /// bytes of escapes and a few thousand. #[test] fn a_run_of_one_color_opens_one_escape() { let mut canvas = Canvas::new(6, 1); canvas.fill(0, 0, 4, '=', Ink::Muted); assert_eq!(canvas.render(true), "\x1b[90m====\x1b[0m\n"); assert_eq!(canvas.render(true).matches("\x1b[").count(), 2); } #[test] fn colors_close_before_the_next_one_opens() { let mut canvas = Canvas::new(4, 1); canvas.put(0, 0, 'A', Ink::of('A')); canvas.put(1, 0, '-', Ink::Muted); canvas.put(2, 0, 'T', Ink::of('T')); assert_eq!( canvas.render(true), "\x1b[92mA\x1b[0m\x1b[90m-\x1b[0m\x1b[91mT\x1b[0m\n" ); } /// An uncolored cell between two colored ones resets without opening /// anything, and the row still closes at the end. #[test] fn default_colored_cells_only_reset() { let mut canvas = Canvas::new(4, 1); canvas.put(0, 0, 'A', Ink::of('A')); canvas.put(1, 0, 'x', Ink::Default); canvas.put(2, 0, 'T', Ink::of('T')); assert_eq!(canvas.render(true), "\x1b[92mA\x1b[0mx\x1b[91mT\x1b[0m\n"); } /// What a pipe, a redirect or `NO_COLOR` gets: the same glyphs, no /// escapes at all — not escapes that happen to be empty. #[test] fn colors_off_emits_no_escapes() { let mut canvas = Canvas::new(20, 3); draw_field(&mut canvas); let plain = canvas.render(false); assert!(!plain.contains('\x1b'), "found an escape in {plain:?}"); // And the glyphs are unchanged by the choice. let stripped: String = canvas .render(true) .replace("\x1b[0m", "") .split('\x1b') .map(|s| s.split_once('m').map_or(s, |(_, rest)| rest)) .collect(); assert_eq!(stripped, plain); } #[test] fn clear_blanks_a_rectangle_and_drops_its_colors() { let mut canvas = Canvas::new(6, 3); draw_field(&mut canvas); canvas.clear(1, 1, 4, 1); for x in 1..5 { assert_eq!(canvas.cells[(canvas.width + x) as usize], BLANK); } } // -- the field ------------------------------------------------------- /// The field is meant to run off all four edges rather than stop short, /// so no row of a frame of any reasonable size comes out empty. #[test] fn the_field_reaches_every_row_at_every_frame_size() { for width in [10, 40, 74, FRAME_COLUMNS] { for height in [1, 7, 19, FRAME_ROWS] { let mut canvas = Canvas::new(width, height); draw_field(&mut canvas); for (row, line) in lines_of(&canvas, false).iter().enumerate() { assert!( !line.trim().is_empty(), "{width}x{height} row {row} is blank" ); } } } } /// Bases only ever appear opposite their complement, which is the one /// thing about the art that is actually true of DNA. #[test] fn every_base_pairs_with_its_complement() { for base in STRAND { assert_eq!(complement(complement(base)), base); } assert_eq!(complement('A'), 'T'); assert_eq!(complement('G'), 'C'); } /// A-T is a two-bond pair and G-C a three-bond one, drawn as `-` and `=`. /// Checked through the field rather than by calling `draw_rung` with /// hand-picked coordinates, so it holds for the rungs actually drawn. #[test] fn bonds_match_the_pair_they_hold_together() { let mut canvas = Canvas::new(FRAME_COLUMNS, FRAME_ROWS); draw_field(&mut canvas); for row in canvas.cells.chunks(canvas.width as usize) { let mut left: Option = None; for cell in row { match cell.ch { c @ ('A' | 'T' | 'G' | 'C') => left = Some(c), bond @ ('-' | '=') => { // The left base of the pair decides the bond glyph. if let Some(base) = left { let expected = if matches!(base, 'A' | 'T') { '-' } else { '=' }; assert_eq!(bond, expected, "{base} held by {bond}"); } } _ => left = None, } } } } #[test] fn bases_keep_their_chromatogram_colors() { let mut canvas = Canvas::new(FRAME_COLUMNS, 19); draw_field(&mut canvas); for cell in &canvas.cells { if matches!(cell.ch, 'A' | 'T' | 'G' | 'C') { assert_eq!(cell.ink, Ink::of(cell.ch), "on {}", cell.ch); } if matches!(cell.ch, '-' | '=') { assert_eq!(cell.ink, Ink::Muted); } } } /// Half a rung either side of the axis, so a helix is at most `2 * /// AMPLITUDE` wide — the number `draw_field` uses to decide how many /// bands could possibly reach the frame. #[test] fn a_rung_never_spans_more_than_the_amplitude_allows() { let mut canvas = Canvas::new(FRAME_COLUMNS, FRAME_ROWS); draw_field(&mut canvas); let span = (2.0 * AMPLITUDE).ceil() as usize + 1; for row in canvas.cells.chunks(canvas.width as usize) { let mut run = 0usize; for cell in row { run = if matches!(cell.ch, '-' | '=') { run + 1 } else { 0 }; assert!(run <= span, "bond run of {run} exceeds {span}"); } } } // -- the panel ------------------------------------------------------- #[test] fn line_widths_account_for_the_label_column() { assert_eq!(width_of(&Line::Blank), 0); assert_eq!(width_of(&Line::Tagline("abc")), 3); assert_eq!(width_of(&Line::Field("version", "0.1.0")), LABEL_WIDTH + 5); // The label's own length does not enter into it — it is padded into // a fixed column, and a label longer than the column would overrun. assert!("version".len() < LABEL_WIDTH); assert!("repo".len() < LABEL_WIDTH); } /// The initials of the tagline spell the project name, so they take the /// wordmark's colors: in ATGC order, once each. The later capitals in /// "A CLI for Tangled" must stay plain, which is what the once-each rule /// buys and what a naive per-character match would get wrong. #[test] fn the_tagline_highlights_atgc_once_each() { let text = env!("CARGO_PKG_DESCRIPTION"); let mut canvas = Canvas::new(text.chars().count() as i32, 1); write_tagline(&mut canvas, 0, 0, text); let colored: Vec<(char, Ink)> = canvas.colored().map(|c| (c.ch, c.ink)).collect(); assert_eq!( colored, vec![ ('A', Ink::of('A')), ('T', Ink::of('T')), ('G', Ink::of('G')), ('C', Ink::of('C')), ], "tagline: {text:?}" ); assert!( text.matches('C').count() > 1, "the fixture should contain a second C for the rule to leave alone" ); } /// Out-of-order capitals are skipped rather than matched: the sequence /// advances only on the letter it is waiting for. #[test] fn the_tagline_matches_in_atgc_order() { let text = "CGTA ATGC"; let mut canvas = Canvas::new(text.len() as i32, 1); write_tagline(&mut canvas, 0, 0, text); let colored: String = canvas.colored().map(|c| c.ch).collect(); // Only the A of "CGTA" is in order; the rest of the sequence has to // wait for T, G, C after it. assert_eq!(colored, "ATGC"); } #[test] fn the_panel_is_a_closed_box_with_the_wordmark_in_its_lid() { let lines = panel_lines(); let content = lines.iter().map(width_of).max().unwrap() as i32; let width = content + 2 * PAD + 2; let height = lines.len() as i32 + 2; let mut canvas = Canvas::new(width + 8, height + 4); draw_field(&mut canvas); draw_panel(&mut canvas, 4, 2, width, &lines); let rendered = lines_of(&canvas, false); let top = &rendered[2]; let bottom = &rendered[2 + height as usize - 1]; assert!(top.contains('╭') && top.contains('╮'), "top: {top}"); assert!( bottom.contains('╰') && bottom.contains('╯'), "bottom: {bottom}" ); // "atgc" is set into the top edge, not printed on a row of its own. assert!(top.contains("atgc"), "top: {top}"); // Every interior row is bounded by the border on both sides. for row in &rendered[3..2 + height as usize - 1] { assert_eq!(row.matches('│').count(), 2, "row: {row}"); } } /// The gutter is the panel's mat: the field is cleared for `GUTTER` /// columns either side so it never crowds the border. #[test] fn the_panel_punches_a_gutter_out_of_the_field() { let lines = panel_lines(); let content = lines.iter().map(width_of).max().unwrap() as i32; let width = content + 2 * PAD + 2; let (x, y) = (6, 2); let mut canvas = Canvas::new(width + 2 * x, lines.len() as i32 + 6); draw_field(&mut canvas); draw_panel(&mut canvas, x, y, width, &lines); for row in y..y + lines.len() as i32 + 2 { for col in [x - GUTTER, x - 1, x + width, x + width + GUTTER - 1] { let cell = canvas.cell(col, row).expect("inside the frame"); assert_eq!(cell.ch, ' ', "field left standing at ({col}, {row})"); } } } /// Too small for the frame, so: the text, nothing else, spacers dropped. /// A five-row terminal has no rows to spend on blank ones. #[test] fn the_bare_fallback_is_text_and_only_text() { let lines = panel_lines(); let content = lines.iter().map(width_of).max().unwrap() as i32; let canvas = draw_bare(&lines, content); let rendered = lines_of(&canvas, false); let expected = lines.iter().filter(|l| width_of(l) > 0).count(); assert_eq!(rendered.len(), expected); assert_eq!(expected, 4, "tagline, version, repo, site"); for row in &rendered { assert!(!row.trim().is_empty()); for border in ['╭', '╮', '╰', '╯', '│', '─'] { assert!( !row.contains(border), "border {border} in bare output: {row}" ); } } // The two URLs are the things someone might copy, so they must // arrive whole rather than cropped to the canvas. for url in [env!("CARGO_PKG_REPOSITORY"), env!("CARGO_PKG_HOMEPAGE")] { assert!( rendered.iter().any(|r| r.contains(url)), "{url} is not in {rendered:?}" ); } } /// The bare canvas is exactly as wide as its widest line, which is what /// leaves a long line for the terminal to wrap rather than truncating it. #[test] fn the_bare_canvas_crops_nothing() { let long = "x".repeat(300); let lines = [Line::Blank, Line::Tagline(&long)]; let content = lines.iter().map(width_of).max().unwrap() as i32; let rendered = lines_of(&draw_bare(&lines, content), false); assert_eq!(rendered, vec![long]); } }